bug fix day 1
This commit is contained in:
parent
b4f05d31cd
commit
e1e55959e1
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
10
app/Models/StaffRole.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class StaffRole extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['name', 'status'];
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
//
|
//
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
133
mobile_api_documentation.txt
Normal file
133
mobile_api_documentation.txt
Normal 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.
|
||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
73
resources/js/Pages/Owner/Components/AccountsTable.jsx
Normal file
73
resources/js/Pages/Owner/Components/AccountsTable.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user