bug fix day 1

This commit is contained in:
ashok 2026-03-13 10:08:46 +05:30
parent b4f05d31cd
commit e1e55959e1
31 changed files with 1142 additions and 329 deletions

View File

@ -9,9 +9,27 @@
class BranchController extends Controller class BranchController extends Controller
{ {
public function index() public function index(Request $request)
{ {
return response()->json(Branch::with('documents')->get()); $query = Branch::with('documents');
if ($request->has('status')) {
$query->where('status', $request->status);
}
$branches = $query->get();
// Attach is_deletable flag to each branch
$branches->each(function ($branch) {
$inUse = \App\Models\Staff::where('branch_id', $branch->id)->exists()
|| \App\Models\Product::where('branch_id', $branch->id)->exists()
|| \App\Models\Expense::where('branch_id', $branch->id)->exists()
|| \App\Models\Collection::where('branch_id', $branch->id)->exists()
|| \App\Models\Account::where('branch_id', $branch->id)->exists()
|| \App\Models\ProductSale::where('branch_id', $branch->id)->exists()
|| \App\Models\Receptionist::where('branch_id', $branch->id)->exists();
$branch->is_deletable = !$inUse;
});
return response()->json($branches);
} }
public function store(Request $request) public function store(Request $request)
@ -89,17 +107,19 @@ public function update(Request $request, $id)
'status' => $validated['status'], 'status' => $validated['status'],
]); ]);
if (isset($validated['new_docs'])) { if ($validated['status'] === 'Inactive') {
foreach ($validated['new_docs'] as $doc) { $staffAction = $request->input('staff_action');
$path = $doc['file']->store('branch_documents', 'public'); if ($staffAction === 'move') {
BranchDocument::create([ $targetBranchId = $request->input('move_to_branch_id');
'branch_id' => $branch->id, if ($targetBranchId) {
'name' => $doc['name'], \App\Models\Staff::where('branch_id', $id)
'document_number' => $doc['document_number'] ?? null, ->where('status', 'Active')
'path' => $path, ->update(['branch_id' => $targetBranchId]);
'expiry_date' => $doc['expiry_date'], }
'reminder_days' => $doc['reminder_days'] ?? 30 } elseif ($staffAction === 'inactivate') {
]); \App\Models\Staff::where('branch_id', $id)
->where('status', 'Active')
->update(['status' => 'Inactive']);
} }
} }
@ -114,9 +134,36 @@ public function show($id)
public function destroy($id) public function destroy($id)
{ {
$branch = Branch::findOrFail($id); $branch = Branch::findOrFail($id);
// Documents will be auto-deleted via cascade constraint in migration
// Check for dependencies
$dependencies = [
'Staff' => \App\Models\Staff::where('branch_id', $id)->exists(),
'Products' => \App\Models\Product::where('branch_id', $id)->exists(),
'Expenses' => \App\Models\Expense::where('branch_id', $id)->exists(),
'Collections' => \App\Models\Collection::where('branch_id', $id)->exists(),
'Accounts' => \App\Models\Account::where('branch_id', $id)->exists(),
'Sales' => \App\Models\ProductSale::where('branch_id', $id)->exists(),
'Receptionists' => \App\Models\Receptionist::where('branch_id', $id)->exists(),
];
$usedIn = array_keys(array_filter($dependencies));
if (!empty($usedIn)) {
return response()->json([
'message' => 'Branch cannot be deleted because it is being used in: ' . implode(', ', $usedIn)
], 422);
}
$branch->delete(); $branch->delete();
return response()->json(['message' => 'Branch deleted successfully']); return response()->json(['message' => 'Branch deleted successfully']);
} }
public function activeStaff($id)
{
$staff = \App\Models\Staff::where('branch_id', $id)
->where('status', 'Active')
->get(['id', 'full_name', 'role']);
return response()->json($staff);
}
} }

View File

@ -39,6 +39,9 @@ public function store(Request $request)
'branch_ids' => 'nullable|array', 'branch_ids' => 'nullable|array',
'branch_ids.*' => 'exists:branches,id', 'branch_ids.*' => 'exists:branches,id',
'security_proof_document' => 'nullable|file|mimes:pdf,png,jpg,jpeg|max:10240', 'security_proof_document' => 'nullable|file|mimes:pdf,png,jpg,jpeg|max:10240',
], [
'security_proof_document.file' => 'The security proof document must be a file.',
'security_proof_document.mimes' => 'The security proof document must be a PDF, PNG, or JPG.',
]); ]);
if ($request->hasFile('security_proof_document')) { if ($request->hasFile('security_proof_document')) {
@ -103,8 +106,25 @@ public function update(Request $request, $id)
'branch_ids' => 'nullable|array', 'branch_ids' => 'nullable|array',
'branch_ids.*' => 'exists:branches,id', 'branch_ids.*' => 'exists:branches,id',
'security_proof_document' => 'nullable|file|mimes:pdf,png,jpg,jpeg|max:10240', 'security_proof_document' => 'nullable|file|mimes:pdf,png,jpg,jpeg|max:10240',
], [
'security_proof_document.file' => 'The security proof document must be a file.',
'security_proof_document.mimes' => 'The security proof document must be a PDF, PNG, or JPG.',
]); ]);
// Check if payouts exist before allowing core financial changes
$hasPayouts = \App\Models\InvestorPayout::where('investor_id', $id)->exists();
if ($hasPayouts) {
$coreFields = ['investment_date', 'investment_amount', 'roi_type', 'roi_value', 'roi_period'];
foreach ($coreFields as $field) {
if (isset($validated[$field]) && $validated[$field] != $investor->$field) {
return response()->json([
'message' => 'Cannot modify core investment terms after payouts have been processed.',
'errors' => [$field => ['Modification restricted due to existing payouts.']]
], 422);
}
}
}
if ($request->hasFile('security_proof_document')) { if ($request->hasFile('security_proof_document')) {
if ($investor->security_proof_document) { if ($investor->security_proof_document) {
Storage::disk('public')->delete($investor->security_proof_document); Storage::disk('public')->delete($investor->security_proof_document);

View File

@ -7,6 +7,7 @@
use App\Models\ExpenseCategory; use App\Models\ExpenseCategory;
use App\Models\ProductCategory; use App\Models\ProductCategory;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Models\StaffRole;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class MasterController extends Controller class MasterController extends Controller
@ -18,6 +19,7 @@ private function getModel($type)
case 'expense': return new ExpenseCategory(); case 'expense': return new ExpenseCategory();
case 'product': return new ProductCategory(); case 'product': return new ProductCategory();
case 'payment_method': return new PaymentMethod(); case 'payment_method': return new PaymentMethod();
case 'staff_role': return new StaffRole();
default: return null; default: return null;
} }
} }

View File

@ -34,14 +34,13 @@ public function getProfitReport(Request $request)
$query->where('date', '<=', $endDate); $query->where('date', '<=', $endDate);
} }
$totalCredits = $query->sum('credit'); $totalCredits = (clone $query)->sum('credit');
$totalDebits = (clone $query)->sum('debit'); $totalDebits = (clone $query)->sum('debit');
// Note: We use Account table for both to ensure consistency with the "Total Received" and "Total Debited" requirement. // Fetch All Ledger Transactions for the breakdown
// If Expenses are also tracked in Accounts as debits (which they should be), this is correct. $accounts = Account::where(function($q) {
// Fetch Transactions for the breakdown $q->where('credit', '>', 0)->orWhere('debit', '>', 0);
$accounts = Account::select('date', 'credit as amount', 'type', 'description') });
->where('credit', '>', 0);
if ($branchId) { if ($branchId) {
$accounts->where('branch_id', $branchId); $accounts->where('branch_id', $branchId);
@ -52,29 +51,28 @@ public function getProfitReport(Request $request)
if ($endDate) { if ($endDate) {
$accounts->where('date', '<=', $endDate); $accounts->where('date', '<=', $endDate);
} }
$accounts = $accounts->get() $accounts = $accounts->get()
->map(function($a) { ->map(function($a) {
$isAdjusted = false; $isAdjusted = false;
$originalAmount = $a->amount; $originalAmount = $a->credit > 0 ? $a->credit : $a->debit;
$remarks = ''; $remarks = '';
if ($a->accountable_type === \App\Models\ProductSale::class && $a->accountable) { if ($a->accountable_type === \App\Models\ProductSale::class && $a->accountable) {
$originalAmount = $a->accountable->subtotal_amount + $a->accountable->vat_amount; $originalAmount = $a->accountable->subtotal_amount + $a->accountable->vat_amount;
$isAdjusted = abs($a->amount - $originalAmount) > 0.01; $isAdjusted = abs($a->credit - $originalAmount) > 0.01;
$remarks = $a->accountable->remarks; $remarks = $a->accountable->remarks;
} elseif ($a->accountable_type === \App\Models\Collection::class && $a->accountable) { } elseif ($a->accountable_type === \App\Models\Collection::class && $a->accountable) {
$originalAmount = $a->accountable->items()->sum('subtotal'); $originalAmount = $a->accountable->items()->sum('subtotal');
$isAdjusted = $originalAmount > 0 && abs($a->amount - $originalAmount) > 0.01; $isAdjusted = $originalAmount > 0 && abs($a->credit - $originalAmount) > 0.01;
$remarks = $a->accountable->remarks; $remarks = $a->accountable->remarks;
} }
return [ return [
'date' => $a->date, 'date' => $a->date,
'type' => 'Income', 'type' => $a->credit > 0 ? 'Income' : 'Expense',
'category' => $a->type, 'category' => $a->type,
'description' => $a->description, 'description' => $a->description,
'amount' => $a->amount, 'amount' => $a->credit > 0 ? $a->credit : $a->debit,
'branch' => 'N/A', 'branch' => 'N/A',
'is_adjusted' => $isAdjusted, 'is_adjusted' => $isAdjusted,
'original_amount' => $originalAmount, 'original_amount' => $originalAmount,
@ -112,12 +110,37 @@ public function getProfitReport(Request $request)
} }
$lowStockCount = $lowStockCount->whereRaw('current_stock <= reorder_level')->count(); $lowStockCount = $lowStockCount->whereRaw('current_stock <= reorder_level')->count();
// Calculate 6-month trend for Dashboard table/chart
$trend = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = Carbon::now()->subMonths($i)->startOfMonth();
$monthEnd = Carbon::now()->subMonths($i)->endOfMonth();
$monthIncome = Account::where('branch_id', $branchId ?: '!=', 0)
->when($branchId, fn($q) => $q->where('branch_id', $branchId))
->whereBetween('date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->sum('credit');
$monthExpense = Expense::when($branchId, fn($q) => $q->where('branch_id', $branchId))
->whereBetween('date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->sum('amount');
$trend[] = [
'month' => $monthStart->format('M'),
'income' => round($monthIncome, 2),
'expense' => round($monthExpense, 2),
'profit' => round($monthIncome - $monthExpense, 2),
'status' => ($monthIncome - $monthExpense) >= 0 ? 'Profit' : 'Loss'
];
}
return response()->json([ return response()->json([
'total_income' => $totalCredits, 'total_income' => $totalCredits,
'total_expense' => $totalDebits, 'total_expense' => $totalDebits,
'net_profit' => $totalCredits - $totalDebits, 'net_profit' => $totalCredits - $totalDebits,
'low_stock_count' => $lowStockCount, 'low_stock_count' => $lowStockCount,
'transactions' => $transactions 'transactions' => $transactions,
'trend' => $trend
]); ]);
} }

View File

@ -10,6 +10,7 @@
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class StaffController extends Controller class StaffController extends Controller
{ {
@ -95,48 +96,56 @@ public function store(Request $request)
$status = $validated['status'] ?? 'Active'; $status = $validated['status'] ?? 'Active';
$validated['status'] = $status; $validated['status'] = $status;
$staff = Staff::create($validated); return DB::transaction(function() use ($validated, $request) {
$staff = Staff::create($validated);
// Handle Salary Advance Deductions // Handle Salary Advance Deductions
if ($staff->advance_enabled) { if ($staff->advance_enabled) {
if ($staff->advance_repayment_mode === 'Divide by Months') { if ($staff->advance_repayment_mode === 'Divide by Months') {
$months = $staff->advance_months ?: 1; $months = $staff->advance_months ?: 1;
$monthlyDeduction = $staff->advance_amount / $months; $monthlyDeduction = $staff->advance_amount / $months;
SalaryAdvanceDeduction::create([ SalaryAdvanceDeduction::create([
'staff_id' => $staff->id, 'staff_id' => $staff->id,
'advance_amount' => $staff->advance_amount, 'advance_amount' => $staff->advance_amount,
'total_months' => $months, 'total_months' => $months,
'monthly_deduction' => $monthlyDeduction, 'monthly_deduction' => $monthlyDeduction,
'remaining_amount' => $staff->advance_amount, 'remaining_amount' => $staff->advance_amount,
'paid_amount' => 0, 'paid_amount' => 0,
'status' => 'Pending' 'status' => 'Pending'
]); ]);
}
// Record immediate account debit and expense for the advance
$this->recordFinancialsForAdvance($staff, $staff->advance_amount);
}
// Handle Documents
if ($request->has('documents')) {
foreach ($request->input('documents') as $index => $doc) {
$path = null;
if ($request->hasFile("documents.{$index}.file")) {
$path = $request->file("documents.{$index}.file")->store('staff_documents', 'public');
} }
$staff->documents()->create([ // Record immediate account debit and expense for the advance
'name' => $doc['name'], $this->recordFinancialsForAdvance($staff, $staff->advance_amount);
'document_number' => $doc['document_number'] ?? null,
'expiry_date' => $doc['expiry_date'] ?? null,
'reminder_days' => $doc['reminder_days'] ?? 30,
'path' => $path,
]);
} }
}
return response()->json(['message' => 'Staff created successfully', 'staff' => $staff], 201); // Handle Documents
if ($request->has('documents')) {
foreach ($request->input('documents') as $index => $doc) {
if (empty($doc['name']) && empty($doc['document_number'])) continue;
$path = null;
if ($request->hasFile("documents.{$index}.file")) {
$path = $request->file("documents.{$index}.file")->store('staff_documents', 'public');
}
if (!$path) {
throw new \Exception("Document file is missing for '{$doc['name']}'");
}
$staff->documents()->create([
'name' => $doc['name'],
'document_number' => $doc['document_number'] ?? null,
'expiry_date' => $doc['expiry_date'] ?? null,
'reminder_days' => $doc['reminder_days'] ?? 30,
'path' => $path,
]);
}
}
return response()->json(['message' => 'Staff created successfully', 'staff' => $staff], 201);
});
} }
public function update(Request $request, $id) public function update(Request $request, $id)
@ -191,114 +200,121 @@ public function update(Request $request, $id)
$status = $validated['status'] ?? 'Active'; $status = $validated['status'] ?? 'Active';
$validated['status'] = $status; $validated['status'] = $status;
$staff->update($validated); return DB::transaction(function() use ($staff, $validated, $request) {
$staff->update($validated);
// Handle Documents Update // Handle Documents Update
if ($request->has('documents')) { if ($request->has('documents')) {
$existingDocIds = []; $existingDocIds = [];
foreach ($request->input('documents') as $index => $doc) { foreach ($request->input('documents') as $index => $doc) {
$path = null; if (empty($doc['name']) && empty($doc['document_number']) && !$request->hasFile("documents.{$index}.file")) continue;
if ($request->hasFile("documents.{$index}.file")) {
$path = $request->file("documents.{$index}.file")->store('staff_documents', 'public');
}
if (isset($doc['id'])) { $path = null;
$staffDoc = \App\Models\StaffDocument::find($doc['id']); if ($request->hasFile("documents.{$index}.file")) {
if ($staffDoc && $staffDoc->staff_id == $staff->id) { $path = $request->file("documents.{$index}.file")->store('staff_documents', 'public');
$updateData = [ }
if (isset($doc['id'])) {
$staffDoc = \App\Models\StaffDocument::find($doc['id']);
if ($staffDoc && $staffDoc->staff_id == $staff->id) {
$updateData = [
'name' => $doc['name'],
'document_number' => $doc['document_number'] ?? null,
'expiry_date' => $doc['expiry_date'] ?? null,
'reminder_days' => $doc['reminder_days'] ?? 30,
];
if ($path) $updateData['path'] = $path;
$staffDoc->update($updateData);
$existingDocIds[] = $staffDoc->id;
}
} else {
if (!$path) {
throw new \Exception("Document file is missing for '{$doc['name']}'");
}
$newDoc = $staff->documents()->create([
'name' => $doc['name'], 'name' => $doc['name'],
'document_number' => $doc['document_number'] ?? null, 'document_number' => $doc['document_number'] ?? null,
'expiry_date' => $doc['expiry_date'] ?? null, 'expiry_date' => $doc['expiry_date'] ?? null,
'reminder_days' => $doc['reminder_days'] ?? 30, 'reminder_days' => $doc['reminder_days'] ?? 30,
]; 'path' => $path,
if ($path) $updateData['path'] = $path; ]);
$staffDoc->update($updateData); $existingDocIds[] = $newDoc->id;
$existingDocIds[] = $staffDoc->id;
} }
} else {
$newDoc = $staff->documents()->create([
'name' => $doc['name'],
'document_number' => $doc['document_number'] ?? null,
'expiry_date' => $doc['expiry_date'] ?? null,
'reminder_days' => $doc['reminder_days'] ?? 30,
'path' => $path,
]);
$existingDocIds[] = $newDoc->id;
} }
// Delete removed documents
$staff->documents()->whereNotIn('id', $existingDocIds)->get()->each(function($doc) {
if ($doc->path) Storage::disk('public')->delete($doc->path);
$doc->delete();
});
} }
// Delete removed documents
$staff->documents()->whereNotIn('id', $existingDocIds)->get()->each(function($doc) {
if ($doc->path) Storage::disk('public')->delete($doc->path);
$doc->delete();
});
}
// Handle Salary Advance Deductions // Handle Salary Advance Deductions
if ($staff->advance_enabled) { if ($staff->advance_enabled) {
// Only record financials if advance amount has increased // Only record financials if advance amount has increased
if ($request->has('advance_amount')) { if ($request->has('advance_amount')) {
$oldAdvance = $staff->getOriginal('advance_amount') ?: 0; $oldAdvance = $staff->getOriginal('advance_amount') ?: 0;
$newAdvance = $staff->advance_amount; $newAdvance = $staff->advance_amount;
if ($newAdvance > $oldAdvance) { if ($newAdvance > $oldAdvance) {
$additionalAmount = $newAdvance - $oldAdvance; $additionalAmount = $newAdvance - $oldAdvance;
$this->recordFinancialsForAdvance($staff, $additionalAmount); $this->recordFinancialsForAdvance($staff, $additionalAmount);
}
}
if ($staff->advance_repayment_mode === 'Divide by Months') {
$existing = SalaryAdvanceDeduction::where('staff_id', $staff->id)->where('status', 'Pending')->first();
$months = $staff->advance_months ?: 1;
$monthlyDeduction = $staff->advance_amount / $months;
if ($existing) {
$existing->update([
'advance_amount' => $staff->advance_amount,
'total_months' => $months,
'monthly_deduction' => $monthlyDeduction,
'remaining_amount' => $staff->advance_amount,
]);
} else {
SalaryAdvanceDeduction::create([
'staff_id' => $staff->id,
'advance_amount' => $staff->advance_amount,
'total_months' => $months,
'monthly_deduction' => $monthlyDeduction,
'remaining_amount' => $staff->advance_amount,
'paid_amount' => 0,
'status' => 'Pending'
]);
}
}
} else {
// If advance was enabled but now disabled, mark active deduction as Closed
if ($staff->getOriginal('advance_enabled')) {
SalaryAdvanceDeduction::where('staff_id', $staff->id)
->where('status', 'Pending')
->update(['status' => 'Closed']);
} }
} }
if ($staff->advance_repayment_mode === 'Divide by Months') { // Handle Trainer Commission History
$existing = SalaryAdvanceDeduction::where('staff_id', $staff->id)->where('status', 'Pending')->first(); if ($staff->commission_enabled && $request->has('commission_member_count')) {
$months = $staff->advance_months ?: 1; $effectiveMonth = $request->input('apply_from') === 'next_month'
$monthlyDeduction = $staff->advance_amount / $months; ? Carbon::now()->addMonth()->format('Y-m')
: Carbon::now()->format('Y-m');
if ($existing) { TrainerCommission::updateOrCreate(
$existing->update([ [
'advance_amount' => $staff->advance_amount,
'total_months' => $months,
'monthly_deduction' => $monthlyDeduction,
'remaining_amount' => $staff->advance_amount,
]);
} else {
SalaryAdvanceDeduction::create([
'staff_id' => $staff->id, 'staff_id' => $staff->id,
'advance_amount' => $staff->advance_amount, 'effective_month' => $effectiveMonth
'total_months' => $months, ],
'monthly_deduction' => $monthlyDeduction, [
'remaining_amount' => $staff->advance_amount, 'member_count' => $request->input('commission_member_count'),
'paid_amount' => 0, 'amount_per_head' => $request->input('commission_amount'),
'status' => 'Pending' 'total_amount' => $request->input('commission_member_count') * $request->input('commission_amount')
]); ]
} );
} }
} else {
// If advance was enabled but now disabled, mark active deduction as Closed
if ($staff->getOriginal('advance_enabled')) {
SalaryAdvanceDeduction::where('staff_id', $staff->id)
->where('status', 'Pending')
->update(['status' => 'Closed']);
}
}
// Handle Trainer Commission History return response()->json(['message' => 'Staff updated successfully', 'staff' => $staff]);
if ($staff->commission_enabled && $request->has('commission_member_count')) { });
$effectiveMonth = $request->input('apply_from') === 'next_month'
? Carbon::now()->addMonth()->format('Y-m')
: Carbon::now()->format('Y-m');
TrainerCommission::updateOrCreate(
[
'staff_id' => $staff->id,
'effective_month' => $effectiveMonth
],
[
'member_count' => $request->input('commission_member_count'),
'amount_per_head' => $request->input('commission_amount'),
'total_amount' => $request->input('commission_member_count') * $request->input('commission_amount')
]
);
}
return response()->json(['message' => 'Staff updated successfully', 'staff' => $staff]);
} }
public function destroy($id) public function destroy($id)

10
app/Models/StaffRole.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class StaffRole extends Model
{
protected $fillable = ['name', 'status'];
}

View File

@ -19,6 +19,8 @@ public function register(): void
*/ */
public function boot(): void public function boot(): void
{ {
// if (config('app.env') !== 'local') {
\Illuminate\Support\Facades\URL::forceScheme('https');
}
} }
} }

View File

@ -11,7 +11,13 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->trustProxies(at: '*');
$middleware->validateCsrfTokens(except: [
'api/*',
'login',
'logout',
'receptionist/login',
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::create('staff_roles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('status')->default('Active');
$table->timestamps();
});
// Seed default roles
DB::table('staff_roles')->insert([
['name' => 'Trainer', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Receptionist', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Manager', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Cleaner', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Security', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
]);
}
public function down(): void
{
Schema::dropIfExists('staff_roles');
}
};

View File

@ -0,0 +1,133 @@
# Flutter Mobile App API Documentation (Owner Role)
Base URL: http://127.0.0.1:8000/api
Note: For testing on a real physical mobile device, replace 127.0.0.1 with your computer's local IP address (e.g., 192.168.1.5).
## Authentication
- POST /login
- Params: email, password
- Returns: CSRF cookie and session (for web-based auth) or Auth token (if configured).
- POST /logout
- Action: End session
- GET /profile
- Returns: Current logged-in user details and role.
## Branch Management
- GET /branches
- List all branches.
- POST /branches
- Create new branch (Multipart/form-data for documents).
- GET /branches/{id}
- View specific branch details.
- PUT /branches/{id}
- Update branch details.
- DELETE /branches/{id}
- Delete a branch.
- GET /branches/{branch}/receptionist
- View receptionist for a branch.
- POST /branches/{branch}/receptionist
- Create/Update receptionist credentials.
- DELETE /branches/{branch}/receptionist
- Remove receptionist.
## Staff Management
- GET /staff
- List all staff members.
- POST /staff
- Add new staff (Multipart/form-data for documents).
- GET /staff/{id}
- View staff profile.
- PUT /staff/{id}
- Update staff profile.
- DELETE /staff/{id}
- Delete staff member.
- GET /staff/pending-salaries
- List all pending salaries across branches.
- POST /staff/bulk-settle
- Params: staff_ids[]
- Settle multiple salaries at once.
- GET /staff/{id}/payments
- Salary payment history.
- GET /staff/{id}/payroll-status
- Current month's payroll calculation.
- POST /staff/{id}/settle
- Settle individual salary for a month.
- GET /staff/{id}/advance-history
- List of advance payments and deductions.
## Investor & ROI Management
- GET /investors
- List all investors.
- POST /investors
- Add new investor (Multipart/form-data for documents).
- GET /investors/{id}
- View investor details.
- PUT /investors/{id}
- Update investor.
- DELETE /investors/{id}
- Delete investor.
- GET /investors/pending-roi
- List all pending ROI settlements.
- GET /investors/{id}/roi-status
- Monthly ROI status breakdown (Base ROI, Carry Over, Paid, Net Due).
- POST /investors/{id}/settle-roi
- Params: payout_month, amount, payout_date, payment_method, remarks.
- Settle a month's ROI.
## Financials & Expenses
- GET /accounts
- General ledger of all credit/debit transactions.
- GET /expenses
- List of all business expenses.
- POST /expenses
- Params: date, branch_id, expense_category_id, expense_type (Account/Petty Cash), amount, remarks.
- Record a new expense.
- GET /expense-categories
- List of master expense categories.
## Inventory Management
- GET /inventory/products
- List all products and stock levels.
- POST /inventory/products
- Add new product with image.
- POST /inventory/products/{id}/adjust
- Adjuts stock (Add/Remove) with remarks.
- GET /inventory/products/{id}/history
- Stock movement history for a specific product.
- GET /inventory/sales
- List of all POS sales.
- POST /inventory/sales
- Record a new product sale.
- GET /inventory/movements
- Global stock movement log.
## Collections
- GET /collections
- List all daily collections.
- POST /collections
- Record new collection.
- GET /collections/{id}
- View collection details.
## Reports
- GET /reports/profit
- Profit & Loss report (Income vs Expenses).
- GET /reports/expiry-reminders
- Documents (Trade license, staff IDs, etc.) expiring soon.
- GET /reports/investments
- Summary of total investments and ROI distributed.
## Master Settings
- GET /masters/{type}
- Types: expense_categories, product_categories, payment_methods, etc.
- POST /masters/{type}
- Add master entry.
- PUT /masters/{type}/{id}
- Edit master entry.
- DELETE /masters/{type}/{id}
- Delete master entry.

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Upload, Calendar, Plus, Trash2 } from 'lucide-react'; import { X, Upload, Calendar, Plus, Trash2 } from 'lucide-react';
import Toast from '../../Components/Toast';
export default function AddBranchModal({ isOpen, onClose, onRefresh }) { export default function AddBranchModal({ isOpen, onClose, onRefresh }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -12,6 +13,7 @@ export default function AddBranchModal({ isOpen, onClose, onRefresh }) {
payroll_to_day: 28, payroll_to_day: 28,
salary_generation_day: 2, salary_generation_day: 2,
}); });
const [toast, setToast] = useState(null);
const [docs, setDocs] = useState([ const [docs, setDocs] = useState([
{ name: '', file: null, document_number: '', expiry_date: '', reminder_days: 30 }, { name: '', file: null, document_number: '', expiry_date: '', reminder_days: 30 },
@ -40,6 +42,14 @@ export default function AddBranchModal({ isOpen, onClose, onRefresh }) {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
// Ensure at least one document is uploaded
const hasFile = docs.some(doc => doc.file);
if (!hasFile) {
setToast({ message: 'Please upload at least one document for the branch.', type: 'error' });
return;
}
setLoading(true); setLoading(true);
const data = new FormData(); const data = new FormData();
@ -71,14 +81,17 @@ export default function AddBranchModal({ isOpen, onClose, onRefresh }) {
}); });
if (res.ok) { if (res.ok) {
onRefresh(); setToast({ message: 'Branch added successfully!', type: 'success' });
onClose(); setTimeout(() => {
onRefresh();
onClose();
}, 1500);
} else { } else {
const err = await res.json(); const err = await res.json();
alert(err.message || 'Error creating branch'); setToast({ message: err.message || 'Error creating branch', type: 'error' });
} }
} catch (error) { } catch (error) {
alert('An error occurred. Please try again.'); setToast({ message: 'An error occurred. Please try again.', type: 'error' });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -88,6 +101,7 @@ export default function AddBranchModal({ isOpen, onClose, onRefresh }) {
return ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-300"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-300">
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
<div className="bg-white w-full max-w-3xl rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col max-h-[90vh]"> <div className="bg-white w-full max-w-3xl rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300 flex flex-col max-h-[90vh]">
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex flex-shrink-0 items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 flex flex-shrink-0 items-center justify-between">
@ -245,7 +259,6 @@ export default function AddBranchModal({ isOpen, onClose, onRefresh }) {
className="hidden" className="hidden"
id={`file-${index}`} id={`file-${index}`}
onChange={(e) => handleDocChange(index, 'file', e.target.files[0])} onChange={(e) => handleDocChange(index, 'file', e.target.files[0])}
required={!doc.file}
/> />
<label htmlFor={`file-${index}`} className={`flex items-center justify-center gap-2 w-full py-2.5 rounded-xl border-2 border-dashed transition-all cursor-pointer text-xs font-bold ${doc.file ? 'bg-emerald-50 border-emerald-200 text-emerald-600' : 'bg-white border-gray-200 text-gray-400 hover:border-red-500 hover:text-red-500'}`}> <label htmlFor={`file-${index}`} className={`flex items-center justify-center gap-2 w-full py-2.5 rounded-xl border-2 border-dashed transition-all cursor-pointer text-xs font-bold ${doc.file ? 'bg-emerald-50 border-emerald-200 text-emerald-600' : 'bg-white border-gray-200 text-gray-400 hover:border-red-500 hover:text-red-500'}`}>
<Upload size={14} /> <Upload size={14} />

View File

@ -15,6 +15,11 @@ export default function EditBranchModal({ isOpen, onClose, onRefresh, branch })
}); });
const [newDocs, setNewDocs] = useState([]); const [newDocs, setNewDocs] = useState([]);
const [activeStaff, setActiveStaff] = useState([]);
const [branches, setBranches] = useState([]); // All branches for "Move to" option
const [staffAction, setStaffAction] = useState('move'); // 'move' or 'inactivate'
const [moveToBranchId, setMoveToBranchId] = useState('');
const [loadingStaff, setLoadingStaff] = useState(false);
useEffect(() => { useEffect(() => {
if (branch) { if (branch) {
@ -29,9 +34,38 @@ export default function EditBranchModal({ isOpen, onClose, onRefresh, branch })
salary_generation_day: branch.salary_generation_day || 2, salary_generation_day: branch.salary_generation_day || 2,
}); });
setNewDocs([]); setNewDocs([]);
setActiveStaff([]);
setStaffAction('move');
setMoveToBranchId('');
} }
}, [branch]); }, [branch]);
// Fetch active staff when status becomes Inactive
useEffect(() => {
if (formData.status === 'Inactive' && branch?.id) {
const fetchActiveStaff = async () => {
setLoadingStaff(true);
try {
const res = await fetch(`/api/branches/${branch.id}/active-staff`);
const data = await res.json();
setActiveStaff(data);
// Fetch other branches for movement
const bRes = await fetch('/api/branches?status=Active');
const bData = await bRes.json();
setBranches(bData.filter(b => b.id !== branch.id));
} catch (error) {
console.error('Error fetching active staff:', error);
} finally {
setLoadingStaff(false);
}
};
fetchActiveStaff();
} else {
setActiveStaff([]);
}
}, [formData.status, branch?.id]);
const handleAddDocRow = () => { const handleAddDocRow = () => {
setNewDocs([...newDocs, { name: '', file: null, document_number: '', expiry_date: '', reminder_days: 30 }]); setNewDocs([...newDocs, { name: '', file: null, document_number: '', expiry_date: '', reminder_days: 30 }]);
}; };
@ -61,6 +95,13 @@ export default function EditBranchModal({ isOpen, onClose, onRefresh, branch })
data.append('payroll_to_day', formData.payroll_to_day); data.append('payroll_to_day', formData.payroll_to_day);
data.append('salary_generation_day', formData.salary_generation_day); data.append('salary_generation_day', formData.salary_generation_day);
if (formData.status === 'Inactive' && activeStaff.length > 0) {
data.append('staff_action', staffAction);
if (staffAction === 'move') {
data.append('move_to_branch_id', moveToBranchId);
}
}
newDocs.forEach((doc, index) => { newDocs.forEach((doc, index) => {
if (doc.file) { if (doc.file) {
data.append(`new_docs[${index}][file]`, doc.file); data.append(`new_docs[${index}][file]`, doc.file);
@ -207,6 +248,62 @@ export default function EditBranchModal({ isOpen, onClose, onRefresh, branch })
<option value="Maintenance">Maintenance</option> <option value="Maintenance">Maintenance</option>
</select> </select>
</div> </div>
{formData.status === 'Inactive' && (loadingStaff ? (
<div className="p-4 bg-gray-50 rounded-xl text-center text-xs text-gray-400">Checking for active staff...</div>
) : activeStaff.length > 0 && (
<div className="p-5 bg-orange-50 rounded-2xl border border-orange-100 space-y-4 animate-in slide-in-from-top-2">
<div className="flex items-center gap-2 text-orange-800">
<User size={16} />
<h4 className="text-sm font-bold mt-1">Active Staff Detected ({activeStaff.length})</h4>
</div>
<p className="text-[11px] text-orange-700 leading-relaxed">
This branch has active staff members. Please choose what to do with them before inactivating the branch.
</p>
<div className="space-y-3">
<div className="flex gap-2">
<button
type="button"
onClick={() => setStaffAction('move')}
className={`flex-1 py-2 text-[10px] font-bold rounded-lg border transition-all ${staffAction === 'move' ? 'bg-orange-500 text-white border-orange-500 shadow-sm' : 'bg-white text-orange-500 border-orange-200 hover:bg-orange-50'}`}
>
Move to Branch
</button>
<button
type="button"
onClick={() => setStaffAction('inactivate')}
className={`flex-1 py-2 text-[10px] font-bold rounded-lg border transition-all ${staffAction === 'inactivate' ? 'bg-orange-500 text-white border-orange-500 shadow-sm' : 'bg-white text-orange-500 border-orange-200 hover:bg-orange-50'}`}
>
Inactivate All
</button>
</div>
{staffAction === 'move' && (
<select
required={staffAction === 'move'}
className="w-full px-3 py-2 bg-white border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 transition-all text-xs font-bold"
value={moveToBranchId}
onChange={(e) => setMoveToBranchId(e.target.value)}
>
<option value="">Select Target Branch *</option>
{branches.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
)}
</div>
<div className="max-h-32 overflow-y-auto no-scrollbar space-y-1 pr-1">
{activeStaff.map(s => (
<div key={s.id} className="px-3 py-1.5 bg-white/50 rounded flex items-center justify-between text-[10px] text-orange-900 border border-orange-100/50">
<span className="font-bold">{s.full_name}</span>
<span className="opacity-60">{s.role}</span>
</div>
))}
</div>
</div>
))}
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ import EditBranchModal from './Components/EditBranchModal';
import DeleteConfirmationModal from './Components/DeleteConfirmationModal'; import DeleteConfirmationModal from './Components/DeleteConfirmationModal';
import DataTable from '../../../Components/DataTable'; import DataTable from '../../../Components/DataTable';
import StatusPill from '../../../Components/StatusPill'; import StatusPill from '../../../Components/StatusPill';
import { Plus, MapPin, Search, Eye, Box, Edit2, Trash2, MoreVertical } from 'lucide-react'; import { Plus, MapPin, Search, Eye, Edit2, Trash2, Lock } from 'lucide-react';
export default function List() { export default function List() {
const [branches, setBranches] = useState([]); const [branches, setBranches] = useState([]);
@ -81,12 +81,6 @@ export default function List() {
> >
<Eye size={18} /> <Eye size={18} />
</button> </button>
<button
title="Inventory"
className="p-1 text-blue-500 hover:scale-110 active:scale-95 transition-all"
>
<Box size={18} />
</button>
<button <button
title="Edit Branch" title="Edit Branch"
onClick={() => setEditModal({ isOpen: true, branch: row })} onClick={() => setEditModal({ isOpen: true, branch: row })}
@ -94,13 +88,22 @@ export default function List() {
> >
<Edit2 size={18} /> <Edit2 size={18} />
</button> </button>
<button {row.is_deletable ? (
title="Delete Branch" <button
onClick={() => setDeleteModal({ isOpen: true, branchId: row.id, branchName: row.name })} title="Delete Branch"
className="p-1 text-red-500 hover:scale-110 active:scale-95 transition-all" onClick={() => setDeleteModal({ isOpen: true, branchId: row.id, branchName: row.name })}
> className="p-1 text-red-500 hover:scale-110 active:scale-95 transition-all"
<Trash2 size={18} /> >
</button> <Trash2 size={18} />
</button>
) : (
<span
title="Branch is in use and cannot be deleted. Inactivate it instead."
className="p-1 text-gray-300 cursor-not-allowed"
>
<Lock size={18} />
</span>
)}
</div> </div>
) )
} }

View File

@ -42,7 +42,7 @@ export default function CollectionsIndex() {
const fetchMetadata = async () => { const fetchMetadata = async () => {
try { try {
const [bRes, tRes] = await Promise.all([ const [bRes, tRes] = await Promise.all([
fetch('/api/branches'), fetch('/api/branches?status=Active'),
fetch('/api/masters/collection') fetch('/api/masters/collection')
]); ]);
if (bRes.ok) setBranches(await bRes.json()); if (bRes.ok) setBranches(await bRes.json());

View File

@ -0,0 +1,73 @@
import React from 'react';
import { MoreVertical, ArrowDownRight, ArrowUpRight } from 'lucide-react';
export default function AccountsTable({ data = [] }) {
const formatCurrency = (val) => {
return new Intl.NumberFormat('en-AE', { style: 'currency', currency: 'AED' }).format(val);
};
return (
<div className="bg-white rounded-[2rem] p-8 border border-gray-100 shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center justify-between mb-8">
<div>
<h3 className="text-xl font-bold text-gray-900">Recent Transactions</h3>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-1">Detailed breakdown of income and expenses.</p>
</div>
<button className="text-gray-400 hover:text-gray-900 transition-colors">
<MoreVertical size={20} />
</button>
</div>
<div className="overflow-x-auto -mx-8">
<table className="w-full">
<thead>
<tr className="text-left border-b border-gray-50 bg-gray-50/30">
<th className="px-8 pb-4 text-[10px] uppercase font-bold text-gray-400 tracking-wider">Date</th>
<th className="px-4 pb-4 text-[10px] uppercase font-bold text-gray-400 tracking-wider">Type</th>
<th className="px-4 pb-4 text-[10px] uppercase font-bold text-gray-400 tracking-wider">Category / Description</th>
<th className="px-8 pb-4 text-right text-[10px] uppercase font-bold text-gray-400 tracking-wider">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{data.length === 0 && (
<tr>
<td colSpan="4" className="py-12 text-center text-sm font-medium text-gray-400 italic">No transactions found for the selected period.</td>
</tr>
)}
{data.map((row, index) => (
<tr key={index} className="group hover:bg-gray-50/50 transition-all">
<td className="px-8 py-5">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">{new Date(row.date).toLocaleDateString()}</span>
<span className="text-[10px] text-gray-400 font-medium uppercase">{new Date(row.date).toLocaleDateString(undefined, { weekday: 'short' })}</span>
</div>
</td>
<td className="px-4 py-5 font-medium">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest ${
row.type === 'Income'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: 'bg-rose-50 text-rose-600 border border-rose-100'
}`}>
{row.type === 'Income' ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
{row.type}
</span>
</td>
<td className="px-4 py-5">
<div className="flex flex-col max-w-md">
<span className="text-sm font-bold text-gray-800 truncate">{row.category}</span>
<span className="text-xs text-gray-400 font-medium truncate">{row.description || 'No description provided'}</span>
</div>
</td>
<td className="px-8 py-5 text-right">
<span className={`text-sm font-black ${row.type === 'Income' ? 'text-emerald-600' : 'text-rose-600'}`}>
{row.type === 'Income' ? '+' : '-'}{formatCurrency(row.amount)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -47,22 +47,8 @@ export default function Header({ profile }) {
<h1 className="text-xl font-extrabold tracking-tight text-gray-900 leading-none">GymPro</h1> <h1 className="text-xl font-extrabold tracking-tight text-gray-900 leading-none">GymPro</h1>
</div> </div>
{/* Search Bar */}
<div className="flex-1 max-w-2xl px-8">
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400 group-focus-within:text-red-500 transition-colors">
<Search size={18} />
</div>
<input
type="text"
placeholder="Search members, plans..."
className="block w-full pl-11 pr-4 py-2.5 bg-gray-50 border border-transparent rounded-xl focus:bg-white focus:border-red-500/30 focus:ring-4 focus:ring-red-500/5 transition-all outline-none text-sm"
/>
</div>
</div>
{/* User Actions */} {/* User Actions */}
<div className="flex items-center gap-6"> <div className="flex items-center gap-6 ml-auto">
<button className="relative p-2 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-full transition-all text-xs font-bold uppercase tracking-widest"> <button className="relative p-2 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-full transition-all text-xs font-bold uppercase tracking-widest">
<Bell size={22} /> <Bell size={22} />
<span className="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 border-2 border-white rounded-full"></span> <span className="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 border-2 border-white rounded-full"></span>
@ -94,19 +80,6 @@ export default function Header({ profile }) {
</div> </div>
<div className="px-2 space-y-1 text-xs font-bold"> <div className="px-2 space-y-1 text-xs font-bold">
<button className="w-full flex items-center gap-3 px-4 py-3 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-2xl transition-all group">
<div className="w-8 h-8 rounded-xl bg-gray-50 flex items-center justify-center group-hover:bg-white border border-transparent group-hover:border-gray-100 transition-all">
<User size={16} />
</div>
<span>Profile Settings</span>
</button>
<button className="w-full flex items-center gap-3 px-4 py-3 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-2xl transition-all group text-xs font-bold">
<div className="w-8 h-8 rounded-xl bg-gray-50 flex items-center justify-center group-hover:bg-white border border-transparent group-hover:border-gray-100 transition-all">
<Shield size={16} />
</div>
<span>Security & Privacy</span>
</button>
<div className="h-px bg-gray-50 mx-4 my-2" /> <div className="h-px bg-gray-50 mx-4 my-2" />
<button <button

View File

@ -1,14 +1,11 @@
import React from 'react'; import React from 'react';
import { MoreVertical } from 'lucide-react'; import { MoreVertical } from 'lucide-react';
const tableData = [ export default function ProfitTable({ data = [] }) {
{ month: 'Jan', income: '0.00 AED', expense: '0.00 AED', profit: '0.00 AED', status: 'Profit' }, const formatCurrency = (val) => {
{ month: 'Feb', income: '0.00 AED', expense: '0.00 AED', profit: '0.00 AED', status: 'Profit' }, return new Intl.NumberFormat('en-AE', { style: 'currency', currency: 'AED' }).format(val);
{ month: 'Mar', income: '0.00 AED', expense: '0.00 AED', profit: '0.00 AED', status: 'Profit' }, };
{ month: 'Apr', income: '0.00 AED', expense: '0.00 AED', profit: '0.00 AED', status: 'Profit' },
];
export default function ProfitTable() {
return ( return (
<div className="bg-white rounded-[2rem] p-8 border border-gray-100 shadow-sm"> <div className="bg-white rounded-[2rem] p-8 border border-gray-100 shadow-sm">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
@ -30,14 +27,25 @@ export default function ProfitTable() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{tableData.map((row, index) => ( {data.length === 0 && (
<tr>
<td colSpan="5" className="py-8 text-center text-sm font-medium text-gray-400 italic">No trend data available for the selected period.</td>
</tr>
)}
{data.map((row, index) => (
<tr key={index} className="group hover:bg-gray-50/50 transition-all"> <tr key={index} className="group hover:bg-gray-50/50 transition-all">
<td className="py-4 text-sm font-medium text-gray-500">{row.month}</td> <td className="py-4 text-sm font-medium text-gray-500">{row.month}</td>
<td className="py-4 text-sm font-bold text-emerald-500">{row.income}</td> <td className="py-4 text-sm font-bold text-emerald-500">{formatCurrency(row.income)}</td>
<td className="py-4 text-sm font-bold text-rose-500">{row.expense}</td> <td className="py-4 text-sm font-bold text-rose-500">{formatCurrency(row.expense)}</td>
<td className="py-4 text-sm font-bold text-blue-500">{row.profit}</td> <td className={`py-4 text-sm font-bold ${row.profit >= 0 ? 'text-blue-500' : 'text-rose-500'}`}>
{formatCurrency(row.profit)}
</td>
<td className="py-4"> <td className="py-4">
<span className="px-3 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-bold uppercase tracking-wider rounded-lg border border-emerald-100"> <span className={`px-3 py-1 text-[10px] font-bold uppercase tracking-wider rounded-lg border ${
row.status === 'Profit'
? 'bg-emerald-50 text-emerald-600 border-emerald-100'
: 'bg-rose-50 text-rose-600 border-rose-100'
}`}>
{row.status} {row.status}
</span> </span>
</td> </td>

View File

@ -1,24 +1,47 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import StatCard from './Components/StatCard'; import StatCard from './Components/StatCard';
import ProfitTable from './Components/ProfitTable'; import AccountsTable from './Components/AccountsTable';
import { DollarSign, TrendingDown, TrendingUp, Calendar, ChevronDown } from 'lucide-react'; import { DollarSign, TrendingDown, TrendingUp, Calendar, ChevronDown } from 'lucide-react';
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
total_income: 0, total_income: 0,
total_expense: 0, total_expense: 0,
net_profit: 0 net_profit: 0,
transactions: []
}); });
const [branches, setBranches] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filterBranch, setFilterBranch] = useState('');
const [startDate, setStartDate] = useState(() => {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().split('T')[0];
});
const [endDate, setEndDate] = useState(new Date().toISOString().split('T')[0]);
useEffect(() => { useEffect(() => {
fetch('/api/reports/profit') fetch('/api/branches?status=Active')
.then(res => res.json())
.then(data => setBranches(data))
.catch(err => console.error("Error fetching branches:", err));
}, []);
useEffect(() => {
setLoading(true);
const params = new URLSearchParams({
branch_id: filterBranch,
start_date: startDate,
end_date: endDate
});
fetch(`/api/reports/profit?${params}`)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
setStats({ setStats({
total_income: data.total_income, total_income: data.total_income,
total_expense: data.total_expense, total_expense: data.total_expense,
net_profit: data.net_profit net_profit: data.net_profit,
transactions: data.transactions || []
}); });
setLoading(false); setLoading(false);
}) })
@ -26,7 +49,7 @@ export default function Dashboard() {
console.error("Error fetching dashboard stats:", err); console.error("Error fetching dashboard stats:", err);
setLoading(false); setLoading(false);
}); });
}, []); }, [filterBranch, startDate, endDate]);
const formatCurrency = (val) => { const formatCurrency = (val) => {
if (val === undefined || val === null || isNaN(val)) return 'AED 0.00'; if (val === undefined || val === null || isNaN(val)) return 'AED 0.00';
@ -43,10 +66,13 @@ export default function Dashboard() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Branch Selector */} {/* Branch Selector */}
<div className="relative group"> <div className="relative group">
<select className="appearance-none pl-4 pr-10 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-600 focus:border-red-500/30 focus:ring-4 focus:ring-red-500/5 transition-all outline-none"> <select
<option>All Branches</option> value={filterBranch}
<option>Downtown Gym</option> onChange={(e) => setFilterBranch(e.target.value)}
<option>Uptown Fitness</option> className="appearance-none pl-4 pr-10 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-600 focus:border-red-500/30 focus:ring-4 focus:ring-red-500/5 transition-all outline-none"
>
<option value="">All Branches</option>
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select> </select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-red-500 transition-all" /> <ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-red-500 transition-all" />
</div> </div>
@ -54,13 +80,23 @@ export default function Dashboard() {
{/* Date Range */} {/* Date Range */}
<div className="flex items-center bg-white border border-gray-200 rounded-xl p-1 gap-1"> <div className="flex items-center bg-white border border-gray-200 rounded-xl p-1 gap-1">
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-gray-500 border-r border-gray-100"> <div className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-gray-500 border-r border-gray-100">
<span className="text-gray-400 font-medium">From</span> <span className="text-gray-400 font-medium whitespace-nowrap">From</span>
<span>31 - 01 - 2026</span> <input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-transparent border-none p-0 focus:ring-0 text-gray-700 outline-none w-28"
/>
<Calendar size={14} className="text-gray-400" /> <Calendar size={14} className="text-gray-400" />
</div> </div>
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-gray-500"> <div className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-gray-500">
<span className="text-gray-400 font-medium">To</span> <span className="text-gray-400 font-medium whitespace-nowrap">To</span>
<span>02 - 03 - 2026</span> <input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="bg-transparent border-none p-0 focus:ring-0 text-gray-700 outline-none w-28"
/>
<Calendar size={14} className="text-gray-400" /> <Calendar size={14} className="text-gray-400" />
</div> </div>
</div> </div>
@ -70,15 +106,15 @@ export default function Dashboard() {
{/* Stat Cards Grid */} {/* Stat Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<StatCard <StatCard
title={loading ? "Loading..." : formatCurrency(stats.total_income)} title="Aggregated amount of all credits"
subtitle="Total Income" subtitle="Total Credited"
value={loading ? "..." : formatCurrency(stats.total_income)} value={loading ? "..." : formatCurrency(stats.total_income)}
color="green" color="green"
icon={DollarSign} icon={DollarSign}
/> />
<StatCard <StatCard
title={loading ? "Loading..." : formatCurrency(stats.total_expense)} title="Aggregated amount of all debits"
subtitle="Total Expenses" subtitle="Total Debited"
value={loading ? "..." : formatCurrency(stats.total_expense)} value={loading ? "..." : formatCurrency(stats.total_expense)}
color="red" color="red"
icon={TrendingDown} icon={TrendingDown}
@ -94,7 +130,7 @@ export default function Dashboard() {
{/* Main Content Area */} {/* Main Content Area */}
<div className="grid grid-cols-1 gap-8"> <div className="grid grid-cols-1 gap-8">
<ProfitTable /> <AccountsTable data={stats.transactions} />
</div> </div>
</main> </main>
</> </>

View File

@ -15,6 +15,21 @@ export default function AddProductModal({ isOpen, onClose, onSave, branches, cat
const isReceptionist = window.__APP_DATA__?.role === 'receptionist'; const isReceptionist = window.__APP_DATA__?.role === 'receptionist';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) {
setFormData({
name: '',
sku: '',
product_category_id: '',
branch_id: window.__APP_DATA__?.role === 'receptionist' ? window.__APP_DATA__?.user?.branch_id : '',
cost_price: '',
selling_price: '',
current_stock: '',
reorder_level: '10'
});
}
}, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@ -49,7 +64,7 @@ export default function AddProductModal({ isOpen, onClose, onSave, branches, cat
return ( return (
<div className="fixed inset-0 bg-[#00171F]/40 backdrop-blur-sm z-[999] flex items-center justify-center p-4"> <div className="fixed inset-0 bg-[#00171F]/40 backdrop-blur-sm z-[999] flex items-center justify-center p-4">
<div className="bg-white rounded-[32px] w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-300"> <div className="bg-white rounded-[32px] w-full max-w-2xl overflow-visible shadow-2xl animate-in zoom-in-95 duration-300">
<div className="p-8 border-b border-gray-100 flex items-center justify-between"> <div className="p-8 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-red-50 rounded-2xl flex items-center justify-center text-red-600"> <div className="w-12 h-12 bg-red-50 rounded-2xl flex items-center justify-center text-red-600">
@ -92,13 +107,17 @@ export default function AddProductModal({ isOpen, onClose, onSave, branches, cat
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Category</label> <label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Category</label>
<select <select
required required
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900 appearance-none" className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900 appearance-none truncate"
value={formData.product_category_id} value={formData.product_category_id}
onChange={e => setFormData({...formData, product_category_id: e.target.value})} onChange={e => setFormData({...formData, product_category_id: e.target.value})}
> >
<option value="">Select Category</option> <option value="">Select Category</option>
{categories.length === 0 && <option disabled>No active categories found. Please add them in Masters.</option>} {categories.length === 0 && <option disabled className="whitespace-normal">No active categories found. Please add them in Masters.</option>}
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {categories.map(c => (
<option key={c.id} value={c.id} className="whitespace-normal" title={c.name}>
{c.name.length > 50 ? c.name.substring(0, 50) + '...' : c.name}
</option>
))}
</select> </select>
</div> </div>

View File

@ -55,7 +55,7 @@ export default function InventoryIndex() {
const fetchMasters = async () => { const fetchMasters = async () => {
try { try {
// Fetch branches // Fetch branches
const bRes = await fetch('/api/branches'); const bRes = await fetch('/api/branches?status=Active');
if (bRes.ok) { if (bRes.ok) {
setBranches(await bRes.json()); setBranches(await bRes.json());
} else { } else {
@ -105,12 +105,7 @@ export default function InventoryIndex() {
}; };
useEffect(() => { useEffect(() => {
const init = async () => { fetchMasters();
setLoading(true);
await Promise.all([fetchProducts(), fetchMasters()]);
setLoading(false);
};
init();
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -261,14 +256,6 @@ export default function InventoryIndex() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button
onClick={() => setIsAdjustModalOpen(true)}
className="flex items-center gap-2 px-6 py-3 bg-white border border-gray-100 text-gray-900 rounded-xl font-black text-xs uppercase tracking-widest hover:bg-gray-50 transition-all shadow-sm"
>
<History size={18} />
<span>Adjust Stock</span>
</button>
<button <button
onClick={() => setIsSaleModalOpen(true)} onClick={() => setIsSaleModalOpen(true)}
className="flex items-center gap-2 px-6 py-3 bg-[#10B981] text-white rounded-xl font-black text-xs uppercase tracking-widest hover:bg-[#059669] transition-all shadow-lg shadow-emerald-100" className="flex items-center gap-2 px-6 py-3 bg-[#10B981] text-white rounded-xl font-black text-xs uppercase tracking-widest hover:bg-[#059669] transition-all shadow-lg shadow-emerald-100"
@ -430,8 +417,8 @@ export default function InventoryIndex() {
<AddProductModal <AddProductModal
isOpen={isAddModalOpen} isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)} onClose={() => setIsAddModalOpen(false)}
onSave={(p) => { onSave={() => {
setProducts([p, ...products]); fetchProducts();
showToast('Product added successfully'); showToast('Product added successfully');
}} }}
branches={branches} branches={branches}
@ -445,7 +432,7 @@ export default function InventoryIndex() {
setSelectedProduct(null); setSelectedProduct(null);
}} }}
onSave={(p) => { onSave={(p) => {
setProducts(products.map(old => old.id === p.id ? p : old)); setProducts(prev => prev.map(old => old.id === p.id ? p : old));
showToast('Stock adjusted successfully'); showToast('Stock adjusted successfully');
}} }}
product={selectedProduct} product={selectedProduct}

View File

@ -28,7 +28,7 @@ export default function InvestorAdd() {
useEffect(() => { useEffect(() => {
const fetchBranches = async () => { const fetchBranches = async () => {
try { try {
const response = await fetch('/api/branches'); const response = await fetch('/api/branches?status=Active');
const data = await response.json(); const data = await response.json();
setBranches(data); setBranches(data);
} catch (error) { } catch (error) {
@ -61,6 +61,19 @@ export default function InvestorAdd() {
const handleSave = async (e) => { const handleSave = async (e) => {
e.preventDefault(); e.preventDefault();
if (saving) return;
if (formData.name.length > 30) {
setToast({ message: 'Investor name cannot exceed 30 characters.', type: 'error' });
return;
}
if (!formData.security_proof_document) {
setToast({ message: 'Please upload a security proof document.', type: 'error' });
return;
}
setSaving(true); setSaving(true);
const data = new FormData(); const data = new FormData();
@ -94,18 +107,16 @@ export default function InvestorAdd() {
} else { } else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
if (errorData.errors) { if (errorData.errors) {
const message = Object.entries(errorData.errors) const message = Object.values(errorData.errors).flat().join('\n');
.map(([field, msgs]) => `${field.replace('_', ' ')}: ${msgs.join(', ')}`)
.join('\n');
setToast({ message: 'Validation Error:\n' + message, type: 'error' }); setToast({ message: 'Validation Error:\n' + message, type: 'error' });
} else { } else {
setToast({ message: 'Error saving investor: ' + (errorData.message || response.statusText), type: 'error' }); setToast({ message: 'Error saving investor: ' + (errorData.message || response.statusText), type: 'error' });
} }
setSaving(false);
} }
} catch (error) { } catch (error) {
console.error('API Error:', error); console.error('API Error:', error);
setToast({ message: 'Failed to save investor.', type: 'error' }); setToast({ message: 'Failed to save investor.', type: 'error' });
} finally {
setSaving(false); setSaving(false);
} }
}; };
@ -140,9 +151,13 @@ export default function InvestorAdd() {
type="text" type="text"
name="name" name="name"
value={formData.name} value={formData.name}
onChange={handleChange} onChange={(e) => {
if (e.target.value.length <= 30) handleChange(e);
else setToast({ message: 'Maximum 30 characters allowed', type: 'error' });
}}
maxLength={30}
className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none" className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none"
placeholder="Enter Investor Name" placeholder="Enter Investor Name (Max 30 chars)"
/> />
</div> </div>

View File

@ -10,6 +10,7 @@ export default function InvestorEdit({ id }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [branches, setBranches] = useState([]); const [branches, setBranches] = useState([]);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [hasPayouts, setHasPayouts] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@ -29,7 +30,7 @@ export default function InvestorEdit({ id }) {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const bRes = await fetch('/api/branches'); const bRes = await fetch('/api/branches?status=Active');
const bData = await bRes.json(); const bData = await bRes.json();
setBranches(bData); setBranches(bData);
@ -44,6 +45,10 @@ export default function InvestorEdit({ id }) {
existing_document: iData.security_proof_document, existing_document: iData.security_proof_document,
security_proof_document: null security_proof_document: null
}); });
const pRes = await fetch(`/api/investors/${id}/payouts`);
const pData = await pRes.json();
setHasPayouts(pData.length > 0);
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
} finally { } finally {
@ -143,22 +148,61 @@ export default function InvestorEdit({ id }) {
<h1 className="text-2xl font-bold text-[#101828]">Edit Investor</h1> <h1 className="text-2xl font-bold text-[#101828]">Edit Investor</h1>
</div> </div>
{hasPayouts && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-100 rounded-2xl flex items-start gap-4 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="p-2 bg-amber-100 rounded-xl text-amber-600">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div className="space-y-1">
<p className="text-sm font-bold text-amber-900 uppercase italic">Financial Terms Locked</p>
<p className="text-xs text-amber-700 font-medium">Core investment terms (Amount, Date, ROI) cannot be modified because payments have already been processed for this investor to maintain accounting integrity.</p>
</div>
</div>
)}
<div className="bg-white rounded-3xl border border-gray-100 shadow-sm p-10"> <div className="bg-white rounded-3xl border border-gray-100 shadow-sm p-10">
<form onSubmit={handleSave} className="space-y-8"> <form onSubmit={handleSave} className="space-y-8">
<div className="grid grid-cols-2 gap-8"> <div className="grid grid-cols-2 gap-8">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[13px] font-semibold text-gray-600">Investor Name</label> <label className="text-[13px] font-semibold text-gray-600">Investor Name</label>
<input required type="text" name="name" value={formData.name} onChange={handleChange} className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none" /> <input
required
type="text"
name="name"
maxLength={30}
value={formData.name}
onChange={(e) => {
if (e.target.value.length <= 30) handleChange(e);
else setToast({ message: 'Maximum 30 characters allowed', type: 'error' });
}}
className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[13px] font-semibold text-gray-600">Investment Date</label> <label className="text-[13px] font-semibold text-gray-600">Investment Date</label>
<input required type="date" name="investment_date" value={formData.investment_date} onChange={handleChange} className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none" /> <input
required
type="date"
name="investment_date"
disabled={hasPayouts}
value={formData.investment_date}
onChange={handleChange}
className={`w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none ${hasPayouts ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100' : ''}`}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[13px] font-semibold text-gray-600">Investment Amount (AED)</label> <label className="text-[13px] font-semibold text-gray-600">Investment Amount (AED)</label>
<input required type="number" name="investment_amount" value={formData.investment_amount} onChange={handleChange} className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none" /> <input
required
type="number"
name="investment_amount"
disabled={hasPayouts}
value={formData.investment_amount}
onChange={handleChange}
className={`w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none ${hasPayouts ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100' : ''}`}
/>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@ -211,7 +255,13 @@ export default function InvestorEdit({ id }) {
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[13px] font-semibold text-gray-600">ROI Type</label> <label className="text-[13px] font-semibold text-gray-600">ROI Type</label>
<select name="roi_type" value={formData.roi_type} onChange={handleChange} className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none appearance-none bg-white"> <select
name="roi_type"
disabled={hasPayouts}
value={formData.roi_type}
onChange={handleChange}
className={`w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none appearance-none bg-white ${hasPayouts ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100' : ''}`}
>
<option value="Percentage">Percentage</option> <option value="Percentage">Percentage</option>
<option value="Fixed Amount">Fixed Amount</option> <option value="Fixed Amount">Fixed Amount</option>
</select> </select>
@ -221,12 +271,25 @@ export default function InvestorEdit({ id }) {
<label className="text-[13px] font-semibold text-gray-600"> <label className="text-[13px] font-semibold text-gray-600">
{formData.roi_type === 'Percentage' ? 'ROI (%)' : 'Fixed Amount (AED)'} {formData.roi_type === 'Percentage' ? 'ROI (%)' : 'Fixed Amount (AED)'}
</label> </label>
<input type="number" name="roi_value" value={formData.roi_value} onChange={handleChange} className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none" /> <input
type="number"
name="roi_value"
disabled={hasPayouts}
value={formData.roi_value}
onChange={handleChange}
className={`w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none ${hasPayouts ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100' : ''}`}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[13px] font-semibold text-gray-600">ROI Period</label> <label className="text-[13px] font-semibold text-gray-600">ROI Period</label>
<select name="roi_period" value={formData.roi_period} onChange={handleChange} className="w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none appearance-none bg-white"> <select
name="roi_period"
disabled={hasPayouts}
value={formData.roi_period}
onChange={handleChange}
className={`w-full border border-gray-200 rounded-xl px-4 py-3.5 text-sm focus:ring-2 focus:ring-emerald-500 transition-all outline-none appearance-none bg-white ${hasPayouts ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100' : ''}`}
>
<option value="Monthly">Monthly</option> <option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option> <option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option> <option value="Yearly">Yearly</option>

View File

@ -168,7 +168,7 @@ export default function InvestorList() {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDeleteModal({ isOpen: true, id: row.id, name: row.name }); setDeleteModal({ isOpen: true, id: row.id });
}} }}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all outline-none" className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all outline-none"
> >
@ -262,7 +262,6 @@ export default function InvestorList() {
onConfirm={() => handleDelete(deleteModal.id)} onConfirm={() => handleDelete(deleteModal.id)}
title="Delete Investor" title="Delete Investor"
message="Are you sure you want to remove this investor? All their investment records and ROI history will be permanently deleted from the financial books." message="Are you sure you want to remove this investor? All their investment records and ROI history will be permanently deleted from the financial books."
itemName={deleteModal.name}
/> />
</main> </main>
</> </>

View File

@ -233,10 +233,17 @@ export default function MasterTable({ type, onNotify, btnLabel }) {
<input <input
type="text" type="text"
required required
maxLength={30}
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => {
if (e.target.value.length <= 30) {
setFormData({ ...formData, name: e.target.value });
} else {
onNotify('Maximum 30 characters allowed', 'error');
}
}}
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 transition-all text-gray-900 placeholder:text-gray-400" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 transition-all text-gray-900 placeholder:text-gray-400"
placeholder={`Enter ${btnLabel.toLowerCase()} name`} placeholder={`Enter ${btnLabel.toLowerCase()} name (Max 30 chars)`}
/> />
</div> </div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-2xl border border-gray-100"> <div className="flex items-center justify-between p-4 bg-gray-50 rounded-2xl border border-gray-100">

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Toast from '../Components/Toast'; import Toast from '../Components/Toast';
import MasterTable from './MasterTable'; import MasterTable from './MasterTable';
import { Tag, Briefcase, Box, Plus, CreditCard } from 'lucide-react'; import { Tag, Briefcase, Box, Plus, CreditCard, Users } from 'lucide-react';
export default function MasterManagement() { export default function MasterManagement() {
const [activeTab, setActiveTab] = useState('collection'); const [activeTab, setActiveTab] = useState('collection');
@ -11,7 +11,8 @@ export default function MasterManagement() {
{ id: 'collection', label: 'Collection Types', icon: Tag, desc: 'Manage categories for payment collection.', btnLabel: 'Type' }, { id: 'collection', label: 'Collection Types', icon: Tag, desc: 'Manage categories for payment collection.', btnLabel: 'Type' },
{ id: 'expense', label: 'Expense Categories', icon: Briefcase, desc: 'Organize various types of business expenses.', btnLabel: 'Category' }, { id: 'expense', label: 'Expense Categories', icon: Briefcase, desc: 'Organize various types of business expenses.', btnLabel: 'Category' },
{ id: 'product', label: 'Product Categories', icon: Box, desc: 'Group products into searchable categories.', btnLabel: 'Category' }, { id: 'product', label: 'Product Categories', icon: Box, desc: 'Group products into searchable categories.', btnLabel: 'Category' },
{ id: 'payment_method', label: 'Payment Methods', icon: CreditCard, desc: 'Manage available payment methods.', btnLabel: 'Method' } { id: 'payment_method', label: 'Payment Methods', icon: CreditCard, desc: 'Manage available payment methods.', btnLabel: 'Method' },
{ id: 'staff_role', label: 'Staff Roles', icon: Users, desc: 'Define staff roles for the staff management module.', btnLabel: 'Role' }
]; ];
const currentTab = tabs.find(t => t.id === activeTab); const currentTab = tabs.find(t => t.id === activeTab);

View File

@ -217,6 +217,122 @@ export default function ReportIndex() {
} }
}; };
const exportToExcel = (data, fileName) => {
if (!data || data.length === 0) {
alert('No data to export');
return;
}
const headers = Object.keys(data[0]);
const csvRows = [];
csvRows.push('\ufeff' + headers.join(',')); // Added BOM for Excel UTF-8 support
for (const row of data) {
const values = headers.map(header => {
let cell = row[header] === null || row[header] === undefined ? '' : row[header];
cell = typeof cell === 'string' ? `"${cell.replace(/"/g, '""')}"` : cell;
return cell;
});
csvRows.push(values.join(','));
}
const csvString = csvRows.join('\n');
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('hidden', '');
a.setAttribute('href', url);
a.setAttribute('download', `${fileName}.csv`);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const exportTabContent = () => {
let dataToExport = [];
let name = activeTab.toLowerCase().replace(/ /g, '_');
if (activeTab === 'Profit Report') {
dataToExport = (profitData?.transactions || []).map(t => ({
Date: new Date(t.date).toLocaleDateString(),
Type: t.type,
Category: t.category,
Description: t.description,
Branch: t.branch,
Amount: t.amount
}));
} else if (activeTab === 'Expense Report') {
dataToExport = expenseData.map(e => ({
Date: new Date(e.date).toLocaleDateString(),
Category: e.category?.name || 'N/A',
Branch: e.branch?.name || 'N/A',
Amount: e.amount
}));
} else if (activeTab === 'Collection Report') {
dataToExport = collectionData.map(c => ({
Date: new Date(c.date).toLocaleDateString(),
Type: c.type?.name || 'N/A',
Branch: c.branch?.name || 'N/A',
Amount: c.amount
}));
} else if (activeTab === 'Low Stock Report') {
dataToExport = lowStockData.map(p => ({
Product: p.name,
Branch: p.branch?.name || 'N/A',
Current_Stock: p.current_stock,
Reorder_Level: p.reorder_level
}));
} else if (activeTab === 'Inventory Report') {
dataToExport = movementsData.map(m => ({
Date: new Date(m.date).toLocaleDateString(),
Product: m.product_name,
Branch: m.branch,
Reason: m.reason,
Change: m.change,
Stock_After: m.new_stock
}));
} else if (activeTab === 'Product Sales') {
dataToExport = salesData.map(s => ({
Date: new Date(s.date).toLocaleDateString(),
Transaction_ID: s.transaction_id,
Branch: s.branch?.name || 'N/A',
Method: s.payment_method?.name || 'N/A',
SubTotal: s.sub_total,
VAT: s.vat_amount,
Total: s.total_amount
}));
} else if (activeTab === 'Investment Report') {
dataToExport = investmentData.map(inv => ({
Investor: inv.investor_name,
Date: new Date(inv.investment_date).toLocaleDateString(),
ROI_Plan: inv.roi_type === 'Percentage' ? `${inv.roi_value}% ${inv.roi_period}` : `${inv.roi_value} AED ${inv.roi_period}`,
Invested_Amount: inv.investment_amount,
Returns_Earned: inv.returns_earned,
Total_Pending: inv.total_pending
}));
} else if (activeTab === 'Salary Report') {
dataToExport = salaryData.map(s => ({
Date: new Date(s.date).toLocaleDateString(),
Type: s.is_bulk ? 'Bulk Release' : 'Individual Release',
Staff_Count: s.count,
Branch: s.branch,
Remarks: s.remarks,
Amount: s.amount
}));
} else if (activeTab === 'Expiry Reminders') {
dataToExport = expiryReminders.map(r => ({
Staff: r.entity_name,
Branch: r.branch_name,
Document: r.document_name,
Doc_Number: r.document_number || '---',
Expiry_Date: new Date(r.expiry_date).toLocaleDateString(),
Status: r.days_left <= 0 ? 'Expired' : 'Expiring Soon'
}));
}
exportToExcel(dataToExport, name);
};
const comingSoon = (tabName) => ( const comingSoon = (tabName) => (
<div className="flex flex-col items-center justify-center py-24 bg-white rounded-3xl border border-dashed border-gray-200 animate-in fade-in duration-500"> <div className="flex flex-col items-center justify-center py-24 bg-white rounded-3xl border border-dashed border-gray-200 animate-in fade-in duration-500">
<div className="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center mb-6 text-gray-400"> <div className="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center mb-6 text-gray-400">
@ -253,24 +369,28 @@ export default function ReportIndex() {
</select> </select>
</div> </div>
)} )}
<div className="min-w-[160px]"> {activeTab !== 'Expiry Reminders' && (
<label className="block text-[9px] font-black text-[#A3AED0] uppercase tracking-widest mb-1.5 ml-1">From Date</label> <>
<input <div className="min-w-[160px]">
type="date" <label className="block text-[9px] font-black text-[#A3AED0] uppercase tracking-widest mb-1.5 ml-1">From Date</label>
className="w-full px-4 py-2.5 bg-[#F4F7FE] border-none rounded-xl text-sm font-bold text-[#1B254B] focus:ring-2 focus:ring-[#E31B1B]/20 outline-none" <input
value={startDate} type="date"
onChange={(e) => setStartDate(e.target.value)} className="w-full px-4 py-2.5 bg-[#F4F7FE] border-none rounded-xl text-sm font-bold text-[#1B254B] focus:ring-2 focus:ring-[#E31B1B]/20 outline-none"
/> value={startDate}
</div> onChange={(e) => setStartDate(e.target.value)}
<div className="min-w-[160px]"> />
<label className="block text-[9px] font-black text-[#A3AED0] uppercase tracking-widest mb-1.5 ml-1">To Date</label> </div>
<input <div className="min-w-[160px]">
type="date" <label className="block text-[9px] font-black text-[#A3AED0] uppercase tracking-widest mb-1.5 ml-1">To Date</label>
className="w-full px-4 py-2.5 bg-[#F4F7FE] border-none rounded-xl text-sm font-bold text-[#1B254B] focus:ring-2 focus:ring-[#E31B1B]/20 outline-none" <input
value={endDate} type="date"
onChange={(e) => setEndDate(e.target.value)} className="w-full px-4 py-2.5 bg-[#F4F7FE] border-none rounded-xl text-sm font-bold text-[#1B254B] focus:ring-2 focus:ring-[#E31B1B]/20 outline-none"
/> value={endDate}
</div> onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</>
)}
</div> </div>
</div> </div>
@ -304,7 +424,7 @@ export default function ReportIndex() {
<div className="bg-white p-8 rounded-[1.5rem] shadow-sm flex items-center justify-between group hover:shadow-lg transition-all border border-transparent hover:border-emerald-100"> <div className="bg-white p-8 rounded-[1.5rem] shadow-sm flex items-center justify-between group hover:shadow-lg transition-all border border-transparent hover:border-emerald-100">
<div> <div>
<p className="text-sm font-bold text-[#A3AED0] mb-2 uppercase tracking-wider">Total Income</p> <p className="text-sm font-bold text-[#A3AED0] mb-2 uppercase tracking-wider">Total Income</p>
<p className="text-2xl font-black text-[#1B254B] tracking-tight">{profitData?.total_credited?.toLocaleString() || '0.00'} AED</p> <p className="text-2xl font-black text-[#1B254B] tracking-tight">{profitData?.total_income?.toLocaleString() || '0.00'} AED</p>
<p className="text-xs font-bold text-[#A3AED0] mt-1">{profitData?.transactions?.filter(t => t.type === 'Income').length || 0} Records</p> <p className="text-xs font-bold text-[#A3AED0] mt-1">{profitData?.transactions?.filter(t => t.type === 'Income').length || 0} Records</p>
</div> </div>
<div className="w-14 h-14 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-500 group-hover:bg-emerald-500 group-hover:text-white transition-all shadow-sm"> <div className="w-14 h-14 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-500 group-hover:bg-emerald-500 group-hover:text-white transition-all shadow-sm">
@ -336,7 +456,10 @@ export default function ReportIndex() {
<div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden"> <div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden">
<div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between"> <div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between">
<h3 className="text-xl font-black text-[#1B254B] tracking-tight">Detailed Breakdown</h3> <h3 className="text-xl font-black text-[#1B254B] tracking-tight">Detailed Breakdown</h3>
<button className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"> <button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} /> <Download size={20} />
Export Data Export Data
</button> </button>
@ -447,7 +570,10 @@ export default function ReportIndex() {
<div className="bg-white rounded-[2.5rem] shadow-sm border border-[#F4F7FE] overflow-hidden"> <div className="bg-white rounded-[2.5rem] shadow-sm border border-[#F4F7FE] overflow-hidden">
<div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between"> <div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between">
<h3 className="text-xl font-black text-[#1B254B] tracking-tight">Detailed Breakdown</h3> <h3 className="text-xl font-black text-[#1B254B] tracking-tight">Detailed Breakdown</h3>
<button className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"> <button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} /> <Download size={20} />
Export Data Export Data
</button> </button>
@ -536,6 +662,13 @@ export default function ReportIndex() {
<div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden"> <div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden">
<div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between"> <div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between">
<h3 className="text-xl font-black text-[#1B254B]">Expense Records</h3> <h3 className="text-xl font-black text-[#1B254B]">Expense Records</h3>
<button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} />
Export
</button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@ -582,6 +715,13 @@ export default function ReportIndex() {
<div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden"> <div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden">
<div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between"> <div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between">
<h3 className="text-xl font-black text-[#1B254B]">Collection Records</h3> <h3 className="text-xl font-black text-[#1B254B]">Collection Records</h3>
<button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} />
Export
</button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@ -645,6 +785,13 @@ export default function ReportIndex() {
<div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden"> <div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden">
<div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between"> <div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between">
<h3 className="text-xl font-black text-[#1B254B]">Low Stock Inventory</h3> <h3 className="text-xl font-black text-[#1B254B]">Low Stock Inventory</h3>
<button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} />
Export
</button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@ -700,7 +847,10 @@ export default function ReportIndex() {
<h3 className="text-xl font-black text-[#1B254B]">Inventory Alerts & Movements</h3> <h3 className="text-xl font-black text-[#1B254B]">Inventory Alerts & Movements</h3>
<p className="text-xs text-gray-500 font-bold mt-1">Global audit log of all stock adjustments and alerts.</p> <p className="text-xs text-gray-500 font-bold mt-1">Global audit log of all stock adjustments and alerts.</p>
</div> </div>
<button className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"> <button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} /> <Download size={20} />
Export Export
</button> </button>
@ -773,7 +923,10 @@ export default function ReportIndex() {
<div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden"> <div className="bg-white rounded-[2rem] shadow-sm border border-[#F4F7FE] overflow-hidden">
<div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between"> <div className="px-10 py-8 border-b border-[#F4F7FE] flex items-center justify-between">
<h3 className="text-xl font-black text-[#1B254B]">Sales Records</h3> <h3 className="text-xl font-black text-[#1B254B]">Sales Records</h3>
<button className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"> <button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} /> <Download size={20} />
Export Export
</button> </button>
@ -855,7 +1008,10 @@ export default function ReportIndex() {
<h3 className="text-xl font-black text-[#1B254B]">Investment Performance</h3> <h3 className="text-xl font-black text-[#1B254B]">Investment Performance</h3>
<p className="text-xs text-gray-500 font-bold mt-1">Overview of investor contributions, returns and pending payouts.</p> <p className="text-xs text-gray-500 font-bold mt-1">Overview of investor contributions, returns and pending payouts.</p>
</div> </div>
<button className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"> <button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} /> <Download size={20} />
Export Export
</button> </button>
@ -923,7 +1079,10 @@ export default function ReportIndex() {
<h3 className="text-xl font-black text-[#1B254B]">Salary Release History</h3> <h3 className="text-xl font-black text-[#1B254B]">Salary Release History</h3>
<p className="text-xs text-gray-500 font-bold mt-1">Overview of all individual and bulk salary payouts.</p> <p className="text-xs text-gray-500 font-bold mt-1">Overview of all individual and bulk salary payouts.</p>
</div> </div>
<button className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"> <button
onClick={exportTabContent}
className="flex items-center gap-2 px-6 py-3 bg-[#00C566] text-white rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-[#00C566]/20 transition-all active:scale-95"
>
<Download size={20} /> <Download size={20} />
Export Export
</button> </button>

View File

@ -6,6 +6,7 @@ export default function StaffAdd() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [branches, setBranches] = useState([]); const [branches, setBranches] = useState([]);
const [roles, setRoles] = useState([]);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const isReceptionist = window.__APP_DATA__?.role === 'receptionist'; const isReceptionist = window.__APP_DATA__?.role === 'receptionist';
@ -42,18 +43,27 @@ export default function StaffAdd() {
}); });
useEffect(() => { useEffect(() => {
const fetchBranches = async () => { const fetchData = async () => {
try { try {
const response = await fetch('/api/branches'); const [bRes, rRes] = await Promise.all([
const data = await response.json(); fetch('/api/branches?status=Active'),
setBranches(data); fetch('/api/masters/staff_role')
]);
const bData = await bRes.json();
const rData = await rRes.json();
setBranches(bData);
const activeRoles = rData.filter(r => r.status === 'Active');
setRoles(activeRoles);
if (activeRoles.length > 0) {
setFormData(prev => ({ ...prev, role: prev.role || activeRoles[0].name }));
}
} catch (error) { } catch (error) {
console.error('Error fetching branches:', error); console.error('Error fetching data:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchBranches(); fetchData();
}, []); }, []);
const handleChange = (e) => { const handleChange = (e) => {
@ -167,6 +177,7 @@ export default function StaffAdd() {
if (!doc.name) errors.push(`Document ${idx + 1}: Name is required.`); if (!doc.name) errors.push(`Document ${idx + 1}: Name is required.`);
if (!doc.document_number) errors.push(`Document ${idx + 1}: Document Number is required.`); if (!doc.document_number) errors.push(`Document ${idx + 1}: Document Number is required.`);
if (!doc.expiry_date) errors.push(`Document ${idx + 1}: Expiry Date is required.`); if (!doc.expiry_date) errors.push(`Document ${idx + 1}: Expiry Date is required.`);
if (!doc.file) errors.push(`Document ${idx + 1}: Please upload a file.`);
} }
}); });
@ -222,7 +233,7 @@ export default function StaffAdd() {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
if (errorData.errors) { if (errorData.errors) {
const message = Object.entries(errorData.errors) const message = Object.entries(errorData.errors)
.map(([field, msgs]) => `${field.replace('_', ' ')}: ${msgs.join(', ')}`) .map(([field, msgs]) => `${field.replace(/_/g, ' ')}: ${msgs.join(', ')}`)
.join('\n'); .join('\n');
setToast({ message: 'Validation Error:\n' + message, type: 'error' }); setToast({ message: 'Validation Error:\n' + message, type: 'error' });
} else { } else {
@ -281,9 +292,10 @@ export default function StaffAdd() {
<div> <div>
<label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Role *</label> <label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Role *</label>
<select name="role" value={formData.role} onChange={handleChange} className="w-full bg-gray-50 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-red-500 transition-all font-medium appearance-none"> <select name="role" value={formData.role} onChange={handleChange} className="w-full bg-gray-50 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-red-500 transition-all font-medium appearance-none">
<option value="Trainer">Trainer</option> {roles.length === 0 && <option value="">No roles configured</option>}
<option value="Receptionist">Receptionist</option> {roles.map(r => (
<option value="Manager">Manager</option> <option key={r.id} value={r.name}>{r.name}</option>
))}
</select> </select>
</div> </div>
{!isReceptionist && ( {!isReceptionist && (

View File

@ -8,6 +8,7 @@ export default function StaffEdit({ id }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [branches, setBranches] = useState([]); const [branches, setBranches] = useState([]);
const [roles, setRoles] = useState([]);
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
@ -49,10 +50,15 @@ export default function StaffEdit({ id }) {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
// Fetch Branches // Fetch Branches and Roles
const bRes = await fetch('/api/branches'); const [bRes, rRes] = await Promise.all([
fetch('/api/branches?status=Active'),
fetch('/api/masters/staff_role')
]);
const bData = await bRes.json(); const bData = await bRes.json();
const rData = await rRes.json();
setBranches(bData); setBranches(bData);
setRoles(rData.filter(r => r.status === 'Active'));
// Fetch Staff Details // Fetch Staff Details
const sRes = await fetch(`/api/staff/${id}`); const sRes = await fetch(`/api/staff/${id}`);
@ -154,9 +160,13 @@ export default function StaffEdit({ id }) {
}; };
const handleDocumentChange = (index, e) => { const handleDocumentChange = (index, e) => {
const { name, value } = e.target; const { name, value, files } = e.target;
const newDocs = [...formData.documents]; const newDocs = [...formData.documents];
newDocs[index][name] = value; if (name === 'file') {
newDocs[index][name] = files[0];
} else {
newDocs[index][name] = value;
}
setFormData({ ...formData, documents: newDocs }); setFormData({ ...formData, documents: newDocs });
}; };
@ -232,6 +242,30 @@ export default function StaffEdit({ id }) {
} }
} }
// Document Validations
for (let i = 0; i < formData.documents.length; i++) {
const doc = formData.documents[i];
if (doc.name || doc.document_number || doc.expiry_date || doc.file) {
if (!doc.name) {
setToast({ message: `Document ${i + 1}: Name is required.`, type: 'error' });
return;
}
if (!doc.document_number) {
setToast({ message: `Document ${i + 1}: Document Number is required.`, type: 'error' });
return;
}
if (!doc.expiry_date) {
setToast({ message: `Document ${i + 1}: Expiry Date is required.`, type: 'error' });
return;
}
// Only require file if it's a new document row (no id) and no existing path
if (!doc.id && !doc.file && !doc.path) {
setToast({ message: `Document ${i + 1}: Please upload a file.`, type: 'error' });
return;
}
}
}
setSaving(true); setSaving(true);
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
@ -284,7 +318,7 @@ export default function StaffEdit({ id }) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
if (errorData.errors) { if (errorData.errors) {
const message = Object.entries(errorData.errors) const message = Object.entries(errorData.errors)
.map(([field, msgs]) => `${field.replace('_', ' ')}: ${msgs.join(', ')}`) .map(([field, msgs]) => `${field.replace(/_/g, ' ')}: ${msgs.join(', ')}`)
.join('\n'); .join('\n');
setToast({ message: 'Validation Error:\n' + message, type: 'error' }); setToast({ message: 'Validation Error:\n' + message, type: 'error' });
} else { } else {
@ -343,9 +377,10 @@ export default function StaffEdit({ id }) {
<div> <div>
<label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Role *</label> <label className="block text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Role *</label>
<select name="role" value={formData.role} onChange={handleChange} className="w-full bg-gray-50 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-red-500 transition-all font-medium appearance-none"> <select name="role" value={formData.role} onChange={handleChange} className="w-full bg-gray-50 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-red-500 transition-all font-medium appearance-none">
<option value="Trainer">Trainer</option> {roles.length === 0 && <option value="">No roles configured</option>}
<option value="Receptionist">Receptionist</option> {roles.map(r => (
<option value="Manager">Manager</option> <option key={r.id} value={r.name}>{r.name}</option>
))}
</select> </select>
</div> </div>
<div> <div>

View File

@ -39,7 +39,7 @@ export default function StaffList() {
const fetchBranches = async () => { const fetchBranches = async () => {
try { try {
const response = await fetch('/api/branches'); const response = await fetch('/api/branches?status=Active');
const data = await response.json(); const data = await response.json();
setBranches(data); setBranches(data);
} catch (error) { } catch (error) {

View File

@ -12,6 +12,7 @@ import {
CheckCircle2, CheckCircle2,
MapPin MapPin
} from 'lucide-react'; } from 'lucide-react';
import Toast from '../Owner/Components/Toast';
export default function POS() { export default function POS() {
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
@ -26,12 +27,18 @@ export default function POS() {
const [adjustmentRemarks, setAdjustmentRemarks] = useState(''); const [adjustmentRemarks, setAdjustmentRemarks] = useState('');
const [branches, setBranches] = useState([]); const [branches, setBranches] = useState([]);
const [selectedBranch, setSelectedBranch] = useState(window.__APP_DATA__?.user?.branch_id || ''); const [selectedBranch, setSelectedBranch] = useState(window.__APP_DATA__?.user?.branch_id || '');
const [toast, setToast] = useState(null);
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
useEffect(() => { useEffect(() => {
if (window.__APP_DATA__?.role === 'owner') { if (window.__APP_DATA__?.role === 'owner') {
const fetchBranches = async () => { const fetchBranches = async () => {
try { try {
const response = await fetch('/api/branches'); const response = await fetch('/api/branches?status=Active');
const data = await response.json(); const data = await response.json();
setBranches(data || []); setBranches(data || []);
if (data?.length > 0 && !selectedBranch) { if (data?.length > 0 && !selectedBranch) {
@ -99,21 +106,33 @@ export default function POS() {
setCart(prev => { setCart(prev => {
const existing = prev.find(item => item.id === product.id); const existing = prev.find(item => item.id === product.id);
if (existing) { if (existing) {
if (existing.quantity >= product.current_stock) {
showToast(`Only ${product.current_stock} units available in stock.`, 'error');
return prev;
}
return prev.map(item => return prev.map(item =>
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
); );
} }
if (product.current_stock <= 0) {
showToast("Product is out of stock.", 'error');
return prev;
}
return [...prev, { ...product, quantity: 1 }]; return [...prev, { ...product, quantity: 1 }];
}); });
}; };
const updateQuantity = (id, delta) => { const updateQuantity = (item, delta) => {
setCart(prev => prev.map(item => { setCart(prev => prev.map(cartItem => {
if (item.id === id) { if (cartItem.id === item.id) {
const newQty = Math.max(1, item.quantity + delta); const newQty = cartItem.quantity + delta;
return { ...item, quantity: newQty }; if (newQty > item.current_stock) {
showToast(`Only ${item.current_stock} units available in stock.`, 'error');
return cartItem;
}
return { ...cartItem, quantity: Math.max(1, newQty) };
} }
return item; return cartItem;
})); }));
}; };
@ -162,6 +181,7 @@ export default function POS() {
return ( return (
<> <>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
<main className="px-6 py-6 max-w-[1600px] mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6 animate-in fade-in duration-500"> <main className="px-6 py-6 max-w-[1600px] mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6 animate-in fade-in duration-500">
{/* Product Catalog Column */} {/* Product Catalog Column */}
<div className="lg:col-span-8 space-y-6"> <div className="lg:col-span-8 space-y-6">
@ -281,9 +301,9 @@ export default function POS() {
</div> </div>
<div className="flex items-center justify-between mt-3"> <div className="flex items-center justify-between mt-3">
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg p-0.5"> <div className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg p-0.5">
<button onClick={() => updateQuantity(item.id, -1)} className="p-1 hover:bg-gray-50 rounded"><Minus size={12} /></button> <button onClick={() => updateQuantity(item, -1)} className="p-1 hover:bg-gray-50 rounded"><Minus size={12} /></button>
<span className="text-[10px] font-black min-w-[16px] text-center">{item.quantity}</span> <span className="text-[10px] font-black min-w-[16px] text-center">{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, 1)} className="p-1 hover:bg-gray-50 rounded"><Plus size={12} /></button> <button onClick={() => updateQuantity(item, 1)} className="p-1 hover:bg-gray-50 rounded"><Plus size={12} /></button>
</div> </div>
<p className="font-black text-gray-900 text-xs">{parseFloat(item.selling_price * item.quantity).toFixed(2)}</p> <p className="font-black text-gray-900 text-xs">{parseFloat(item.selling_price * item.quantity).toFixed(2)}</p>
</div> </div>

View File

@ -48,6 +48,7 @@
Route::get('/', [BranchController::class, 'index']); Route::get('/', [BranchController::class, 'index']);
Route::post('/', [BranchController::class, 'store']); Route::post('/', [BranchController::class, 'store']);
Route::get('/{id}', [BranchController::class, 'show']); Route::get('/{id}', [BranchController::class, 'show']);
Route::get('/{id}/active-staff', [BranchController::class, 'activeStaff']);
Route::put('/{id}', [BranchController::class, 'update']); Route::put('/{id}', [BranchController::class, 'update']);
Route::delete('/{id}', [BranchController::class, 'destroy']); Route::delete('/{id}', [BranchController::class, 'destroy']);
}); });