feat: Invoice Changes

This commit is contained in:
brian-inspiren 2026-05-22 16:09:17 +08:00
parent 3f319aae4f
commit 6edadb1b04
21 changed files with 506 additions and 125 deletions

View File

@ -92,17 +92,26 @@ public function handle()
// } else {
$spend = 0;
// }
$managementFee = intval(str_replace(',', '', $row['management_fee'])) ?? 0;
$mediaFee = intval(str_replace(',', '', $row['media_fee'])) ?? 0;
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0;
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0;
$invoice = ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']],
[
'client_id' => $row['client_id'],
'is_credit_card' => intval(str_replace(',', '',$row['media_fee'])) == 0 ? 1 : 0,
'is_credit_card' => $mediaFee == 0 ? 1 : 0,
'start_date' => $startDate,
'end_date' => $endDate,
'management_fee' => intval(str_replace(',', '',$row['management_fee'])) ?? 0,
'media_fee' => intval(str_replace(',', '',$row['media_fee'])) ?? 0,
'management_fee' => $managementFee,
'management_fee_amount' => $managementFeeAmount,
'management_fee_tax' => $managementFee - $managementFeeAmount,
'media_fee' => $mediaFee,
'media_fee_amount' => $mediaFeeAmount,
'media_fee_tax' => $mediaFee - $mediaFeeAmount,
'tax_percent' => 8,
'nett_amount' => intval(str_replace(',', '',$row['media_fee'])) > 0 ? intval(str_replace(',', '',$row['media_fee'])) / 1.08 : 0,
'nett_amount' => $mediaFeeAmount,
'total_spending' => $spend,
]
);

View File

@ -20,6 +20,7 @@ public function handle()
$mccCustomerId = env('GOOGLE_ADS_LOGIN_CUSTOMER_ID'); // Manager ID without dashes
$adsService = new GoogleAdsService();
$accounts = $adsService->listAccounts();
Log::info('Fetched accounts from Google Ads', ['accounts' => $accounts]);
foreach ($accounts as $account) {
$company = Client::updateOrCreate(
['customer_id' => $account['id']],

View File

@ -36,7 +36,11 @@ public function pending(): JsonResponse
'end_date',
'payment_no',
'management_fee',
'management_fee_amount',
'management_fee_tax',
'media_fee',
'media_fee_amount',
'media_fee_tax',
'nett_amount',
'total_spending',
'created_at',
@ -72,14 +76,18 @@ public function store(Request $request): JsonResponse
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'management_fee' => ['required', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
$mediaFee = $validated['media_fee'];
$taxPercent = $validated['tax_percent'] ?? 0;
$nettAmount = $mediaFee - ($mediaFee * ($taxPercent / 100));
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null);
$client = ! empty($validated['client_id'])
? \App\Models\Client::find($validated['client_id'])
@ -111,8 +119,12 @@ public function store(Request $request): JsonResponse
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'management_fee' => $validated['management_fee'],
'management_fee_amount' => $validated['management_fee_amount'] ?? null,
'management_fee_tax' => $validated['management_fee_tax'] ?? null,
'media_fee' => $validated['media_fee'],
'tax_percent' => $taxPercent,
'media_fee_amount' => $validated['media_fee_amount'] ?? null,
'media_fee_tax' => $validated['media_fee_tax'] ?? null,
'tax_percent' => null,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
]);

View File

@ -86,7 +86,11 @@ public function store(Request $request)
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'management_fee' => ['required', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
@ -95,8 +99,8 @@ public function store(Request $request)
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$mediaFee = $validated['media_fee'];
$taxPercent = $validated['tax_percent'] ?? 0;
$nettAmount = $mediaFee - ($mediaFee * ($taxPercent / 100));
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
$invoice = ClientInvoice::create([
'client_id' => $validated['client_id'],
@ -109,7 +113,11 @@ public function store(Request $request)
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'management_fee' => $validated['management_fee'],
'management_fee_amount' => $validated['management_fee_amount'] ?? null,
'management_fee_tax' => $validated['management_fee_tax'] ?? null,
'media_fee' => $validated['media_fee'],
'media_fee_amount' => $validated['media_fee_amount'] ?? null,
'media_fee_tax' => $validated['media_fee_tax'] ?? null,
'tax_percent' => $taxPercent,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
@ -145,15 +153,20 @@ public function update(Request $request, ClientInvoice $invoice)
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'amount' => ['required', 'numeric', 'min:0'],
'management_fee' => ['required', 'numeric', 'min:0'],
'management_fee_amount' => ['nullable', 'numeric', 'min:0'],
'management_fee_tax' => ['nullable', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'media_fee_amount' => ['nullable', 'numeric', 'min:0'],
'media_fee_tax' => ['nullable', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'nett_amount' => ['nullable', 'numeric', 'min:0'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
$managementFee = $validated['management_fee'];
$mediaFee = $validated['media_fee'];
$taxPercent = (float) ($validated['tax_percent'] ?? 0);
$nettAmount = $mediaFee / (1 + ($taxPercent / 100));
$nettAmount = $validated['nett_amount'] ?? ($mediaFee / (1 + ($taxPercent / 100)));
$invoice->update([
'invoice_no' => $validated['invoice_no'],
@ -171,6 +184,10 @@ public function update(Request $request, ClientInvoice $invoice)
'total_spending' => $validated['total_spending'] ?? null,
]);
if(empty($invoice->approved_at)) {
$this->approvalService->approve($invoice);
}
return redirect()
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
->with('message-info', 'Invoice updated successfully.');
@ -315,10 +332,6 @@ public function storeClient(Request $request, ClientInvoice $invoice)
$selectedClientIsLinked = Client::query()
->where('id', $validated['client_id'])
->whereHas('customers', function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->exists();
if ($selectedClientIsLinked) {

View File

@ -250,7 +250,7 @@ private function hydrateClient(array $account): array
'time_zone' => $account['time_zone'],
]
);
// dd($localClient);
$localClient->load(['assignations.user', 'invoices']);
$assignments = $localClient->assignations
@ -327,7 +327,11 @@ private function hydrateClient(array $account): array
'amount' => $invoice->amount,
'total_spend' => number_format($totalInvoiceSpend, 2, '.', ''),
'management_fee' => $invoice->management_fee,
'management_fee_amount' => $invoice->management_fee_amount,
'management_fee_tax' => $invoice->management_fee_tax,
'media_fee' => $invoice->media_fee,
'media_fee_amount' => $invoice->media_fee_amount,
'media_fee_tax' => $invoice->media_fee_tax,
'tax_percent' => $invoice->tax_percent,
'nett_amount' => $invoice->nett_amount,
'total_spending' => $invoice->total_spending,
@ -467,17 +471,26 @@ public function insertCSVDataToDB()
}
}
}
$managementFee = intval($row['management_fee']);
$mediaFee = intval($row['media_fee']);
$managementFeeAmount = $managementFee > 0 ? $managementFee / 1.08 : 0;
$mediaFeeAmount = $mediaFee > 0 ? $mediaFee / 1.08 : 0;
ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']],
[
'client_id' => $row['client_id'],
'is_credit_card' => intval($row['media_fee']) == 0 ? 1 : 0,
'is_credit_card' => $mediaFee == 0 ? 1 : 0,
'start_date' => $startDate,
'end_date' => $endDate,
'management_fee' => intval($row['management_fee']),
'media_fee' => intval($row['media_fee']),
'management_fee' => $managementFee,
'management_fee_amount' => $managementFeeAmount,
'management_fee_tax' => $managementFee - $managementFeeAmount,
'media_fee' => $mediaFee,
'media_fee_amount' => $mediaFeeAmount,
'media_fee_tax' => $mediaFee - $mediaFeeAmount,
'tax_percent' => 8,
'nett_amount' => intval($row['media_fee']) > 0 ? intval($row['media_fee']) / 1.08 : 0,
'nett_amount' => $mediaFeeAmount,
'total_spending' => $spend,
]
);

View File

@ -64,21 +64,28 @@ public function edit(int $id): Response
{
$role = Role::findOrFail($id);
// 1. Get all permissions with their "checked" state
$permissions = Permission::all()->map(function ($permission) use ($role) {
$permissions = Permission::query()
->orderBy('group')
->orderBy('name')
->get()
->map(function ($permission) use ($role) {
$group = $permission->group
?: $permission->group_name
?: str($permission->name)->before('.')->headline()->toString();
return [
'id' => $permission->id,
'name' => $permission->name, // e.g. "user.create"
'name' => $permission->name,
'group' => $group,
'group_name' => $permission->group_name,
'description' => $permission->description,
'checked' => $role->hasPermissionTo($permission->name),
];
});
// 2. Group them by the prefix (the part before the dot)
$grouped = $permissions->groupBy(function ($item) {
return explode('.', $item['name'])[0];
return $item['group'];
})->map(function ($group) {
// 3. Force it to be a sequential array so JS sees it as []
return $group->values()->toArray();
});

View File

@ -26,7 +26,11 @@ class ClientInvoice extends Model
'amount',
'tax_percent',
'media_fee',
'media_fee_amount',
'media_fee_tax',
'management_fee',
'management_fee_amount',
'management_fee_tax',
'nett_amount',
'total_spending',
];
@ -40,7 +44,11 @@ class ClientInvoice extends Model
'amount' => 'decimal:2',
'tax_percent' => 'decimal:2',
'media_fee' => 'decimal:2',
'media_fee_amount' => 'decimal:2',
'media_fee_tax' => 'decimal:2',
'management_fee' => 'decimal:2',
'management_fee_amount' => 'decimal:2',
'management_fee_tax' => 'decimal:2',
'nett_amount' => 'decimal:2',
'total_spending' => 'decimal:2',
];

View File

@ -108,7 +108,6 @@ public function listCampaigns(string $clientCustomerId): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$customerId = str_replace('-', '', $clientCustomerId);
$query = <<<QUERY
@ -136,6 +135,7 @@ public function listCampaigns(string $clientCustomerId): array
]);
$response = $service->search($request);
$campaigns = [];
foreach ($response->iterateAllElements() as $row) {
$c = $row->getCampaign();

View File

@ -6,6 +6,8 @@
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@ -15,6 +17,11 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'permission' => PermissionMiddleware::class,
'role' => RoleMiddleware::class,
]);
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [

View File

@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('client_invoices', 'management_fee_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->decimal('management_fee_amount', 10, 2)->nullable()->after('management_fee');
});
}
if (! Schema::hasColumn('client_invoices', 'management_fee_tax')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->decimal('management_fee_tax', 10, 2)->nullable()->after('management_fee_amount');
});
}
if (! Schema::hasColumn('client_invoices', 'media_fee_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->decimal('media_fee_amount', 10, 2)->nullable()->after('media_fee');
});
}
if (! Schema::hasColumn('client_invoices', 'media_fee_tax')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->decimal('media_fee_tax', 10, 2)->nullable()->after('media_fee_amount');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('client_invoices', 'media_fee_tax')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->dropColumn('media_fee_tax');
});
}
if (Schema::hasColumn('client_invoices', 'media_fee_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->dropColumn('media_fee_amount');
});
}
if (Schema::hasColumn('client_invoices', 'management_fee_tax')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->dropColumn('management_fee_tax');
});
}
if (Schema::hasColumn('client_invoices', 'management_fee_amount')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->dropColumn('management_fee_amount');
});
}
}
};

View File

@ -15,7 +15,9 @@ public function run(): void
{
// User::factory(10)->create();
User::firstOrCreate(
$this->call(RoleSeeder::class);
$user = User::firstOrCreate(
['email' => 'test@example.com'],
[
'name' => 'Test User',
@ -23,5 +25,7 @@ public function run(): void
'email_verified_at' => now(),
]
);
$user->assignRole('Admin');
}
}

View File

@ -2,12 +2,10 @@
namespace Database\Seeders;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\PermissionRegistrar;
class RoleSeeder extends Seeder
{
@ -16,8 +14,51 @@ class RoleSeeder extends Seeder
*/
public function run(): void
{
$adminRole = Role::create(['name' => 'Admin','guard_name' => 'web']);
$salesRole = Role::create(['name' => 'Sales' ,'guard_name' => 'web']);
$socialMedia = Role::create(['name' => 'Social Media Specialist','guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$permissions = [
['name' => 'dashboard.view', 'group' => 'Dashboard', 'description' => 'View dashboard'],
['name' => 'google-ads.accounts.view', 'group' => 'Google Ads Accounts', 'description' => 'View Google Ads accounts'],
['name' => 'google-ads.accounts.sync', 'group' => 'Google Ads Accounts', 'description' => 'Sync Google account records'],
['name' => 'google-ads.accounts.update', 'group' => 'Google Ads Accounts', 'description' => 'Update Google Ads account assignment details'],
['name' => 'google-ads.activities.create', 'group' => 'Google Ads Activities', 'description' => 'Create account activities'],
['name' => 'google-ads.activities.update', 'group' => 'Google Ads Activities', 'description' => 'Update account activities'],
['name' => 'google-ads.activities.complete', 'group' => 'Google Ads Activities', 'description' => 'Mark account activities as complete'],
['name' => 'google-ads.activities.delete', 'group' => 'Google Ads Activities', 'description' => 'Delete account activities'],
['name' => 'google-ads.import', 'group' => 'Google Ads Accounts', 'description' => 'Import Google Ads data'],
['name' => 'google.reports.view', 'group' => 'Google Reports', 'description' => 'View Google campaign reports'],
['name' => 'client-invoices.create', 'group' => 'Client Invoices', 'description' => 'Create client invoices'],
['name' => 'client-invoices.create-client', 'group' => 'Client Invoices', 'description' => 'Create clients from pending invoices'],
['name' => 'client-invoices.update', 'group' => 'Client Invoices', 'description' => 'Update client invoices'],
['name' => 'client-invoices.approve', 'group' => 'Client Invoices', 'description' => 'Approve client invoices'],
['name' => 'client-invoices.delete', 'group' => 'Client Invoices', 'description' => 'Delete client invoices'],
['name' => 'client-invoices.view-pdf', 'group' => 'Client Invoices', 'description' => 'View invoice PDF'],
['name' => 'clients.adjustments.create', 'group' => 'Client Adjustments', 'description' => 'Create client invoice adjustments'],
['name' => 'clients.adjustments.delete', 'group' => 'Client Adjustments', 'description' => 'Delete client invoice adjustments'],
['name' => 'management.roles.view', 'group' => 'Role Management', 'description' => 'View roles'],
['name' => 'management.roles.update', 'group' => 'Role Management', 'description' => 'Update role permissions'],
['name' => 'management.users.view', 'group' => 'User Management', 'description' => 'View users'],
['name' => 'management.users.create', 'group' => 'User Management', 'description' => 'Create users'],
['name' => 'management.users.update', 'group' => 'User Management', 'description' => 'Update users'],
];
foreach ($permissions as $permission) {
Permission::query()->updateOrCreate(
['name' => $permission['name'], 'guard_name' => 'web'],
[
'group' => $permission['group'],
'group_name' => $permission['group'],
'description' => $permission['description'],
],
);
}
$adminRole = Role::query()->firstOrCreate(['name' => 'Admin', 'guard_name' => 'web']);
Role::query()->firstOrCreate(['name' => 'Sales', 'guard_name' => 'web']);
Role::query()->firstOrCreate(['name' => 'Social Media Specialist', 'guard_name' => 'web']);
$adminRole->syncPermissions(Permission::query()->pluck('name')->all());
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
}

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { InertiaFormProps } from "@inertiajs/react";
import { route } from "ziggy-js";
import axios from "axios";
import { Button, Stack, TextInput, NumberInput, Loader, Select, Switch, Text } from "@mantine/core";
import { Button, Group, Stack, TextInput, NumberInput, Loader, Select, Switch, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { IconDeviceFloppy } from "@tabler/icons-react";
import dayjs from "dayjs";
@ -17,11 +17,16 @@ export interface InvoiceFormValues {
end_date: string;
amount: string;
management_fee: string;
management_fee_amount?: string;
management_fee_tax?: string;
media_fee: string;
media_fee_amount?: string;
media_fee_tax?: string;
tax_percent: string;
total_spending: string;
client_id?: string;
customer_id?: string;
nett_amount: string;
}
interface InvoiceOption {
@ -45,13 +50,30 @@ export default function InvoiceForm({
const formatDate = (value: string) => (value ? dayjs(value).toDate() : null);
const [fetchingSpend, setFetchingSpend] = useState(false);
const parseAmount = (value?: string) => {
const parsed = Number.parseFloat(value ?? "");
return Number.isFinite(parsed) ? parsed : 0;
};
const formatAmount = (value: number) => value.toFixed(2);
const calculateNettAmount = () => {
const mediaFeeAmount = form.data.media_fee;
const calculated = formatAmount(
parseAmount(mediaFeeAmount) / (1 + parseAmount(form.data.tax_percent) / 100)
);
form.setData("nett_amount", calculated);
};
useEffect(() => {
if (!form.data.is_credit_card) return;
// Credit-card invoices can be fee-free; normalize fields to 0 for convenience.
if (form.data.management_fee !== "0") form.setData("management_fee", "0");
// if (form.data.management_fee !== "0") form.setData("management_fee", "0");
if (form.data.media_fee !== "0") form.setData("media_fee", "0");
if (form.data.tax_percent !== "0") form.setData("tax_percent", "0");
// if (form.data.tax_percent !== "0") form.setData("tax_percent", "0");
}, [form.data.is_credit_card]);
useEffect(() => {
@ -183,6 +205,21 @@ export default function InvoiceForm({
required
/>
<Group align="flex-end">
<NumberInput
precision={2}
label="Media Nett Amount (RM)"
value={form.data.nett_amount ? Number(form.data.nett_amount) : undefined}
onChange={(value) => form.setData("nett_amount", value?.toString() ?? "")}
error={form.errors.nett_amount}
required
style={{ flex: 1 }}
/>
<Button type="button" variant="outline" onClick={calculateNettAmount}>
Calculate
</Button>
</Group>
<NumberInput
precision={2}
label="Total Spending (RM)"
@ -192,17 +229,20 @@ export default function InvoiceForm({
/>
<Text>Additional Info</Text>
<Group spacing="sm">
<Switch
label="Credit card"
aria-label="Credit card"
checked={form.data.is_credit_card}
onChange={(event) => form.setData("is_credit_card", event.currentTarget.checked)}
/>
<Text>Credit card</Text>
</Group>
<Switch
{/* <Switch
label="Paid"
checked={form.data.is_paid}
onChange={(event) => form.setData("is_paid", event.currentTarget.checked)}
/>
/> */}
<Button
type="submit"

View File

@ -62,7 +62,7 @@ export default function UserForm({ role, status, permissions }: Props) {
return (
<Stack key={group} spacing="xs">
<Checkbox
label={<Text fw={600} sx={{ textTransform: 'capitalize' }}>{group}</Text>}
label={<Text fw={600}>{group}</Text>}
checked={allChecked}
indeterminate={someChecked && !allChecked}
onChange={(e) => handleGroupCheckboxChange(group, e.target.checked)}

View File

@ -154,29 +154,47 @@ function AppNotifications() {
accessorFn: (invoice) => invoice.pending_sql_acc_code ?? '-',
Cell: ({ cell }) => <Text size="sm">{cell.getValue<string>()}</Text>,
},
{
id: 'invoice_management_fee',
header: 'Invoice Management Fee',
accessorFn: (invoice) => invoice.invoice_billing_totals?.management_fee ?? 0,
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.invoice_billing_totals?.management_fee ?? 0)}
</Text>
),
},
{
id: 'invoice_media_fee',
header: 'Invoice Media Fee',
accessorFn: (invoice) => invoice.invoice_billing_totals?.media_fee ?? 0,
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.invoice_billing_totals?.media_fee ?? 0)}
</Text>
),
},
// {
// id: 'invoice_management_fee',
// header: 'Invoice Management Fee',
// accessorFn: (invoice) => invoice.invoice_billing_totals?.management_fee ?? 0,
// Cell: ({ row }) => (
// <Text size="sm" weight={600} align="right">
// {formatAmount(row.original.invoice_billing_totals?.management_fee ?? 0)}
// </Text>
// ),
// },
// {
// id: 'invoice_media_fee',
// header: 'Invoice Media Fee',
// accessorFn: (invoice) => invoice.invoice_billing_totals?.media_fee ?? 0,
// Cell: ({ row }) => (
// <Text size="sm" weight={600} align="right">
// {formatAmount(row.original.invoice_billing_totals?.media_fee ?? 0)}
// </Text>
// ),
// },
{
accessorKey: 'management_fee',
header: 'Payment Management Fee',
header: 'Payment Management Fee (incl. tax)',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.management_fee_amount)}
</Text>
),
},
{
accessorKey: 'management_fee_tax',
header: 'Payment Management Tax',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.management_fee_tax)}
</Text>
),
},
{
accessorKey: 'management_fee_nett',
header: 'Payment Management Nett',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.management_fee)}
@ -185,7 +203,25 @@ function AppNotifications() {
},
{
accessorKey: 'media_fee',
header: 'Payment Media Fee',
header: 'Payment Media Fee (incl. tax)',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.media_fee_amount)}
</Text>
),
},
{
accessorKey: 'media_fee_tax',
header: 'Payment Media Tax',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.media_fee_tax)}
</Text>
),
},
{
accessorKey: 'media_fee_nett',
header: 'Payment Media Nett',
Cell: ({ row }) => (
<Text size="sm" weight={600} align="right">
{formatAmount(row.original.media_fee)}
@ -617,7 +653,7 @@ export default function AppLayout({ children }: Props) {
</Group>
<Group spacing="sm">
<AppNotifications />
{/* <AppNotifications /> */}
<ThemeToggle />
<Menu shadow="md" width={220}>
<Menu.Target>

View File

@ -9,6 +9,7 @@ import {
ActionIcon,
Stack,
Text,
Tabs,
} from '@mantine/core';
import { IconEye, IconRefresh } from '@tabler/icons-react';
import { MantineReactTable } from 'mantine-react-table';
@ -35,13 +36,60 @@ const parseAmount = (value?: number | string | null): number => {
return Number.isNaN(normalized) ? 0 : normalized;
};
const statusOrder = ['ENABLED', 'PAUSED', 'REMOVED', 'ENDED', 'CANCELED'];
const getStatusColor = (status: string) => {
if (status === 'ENABLED') {
return 'green';
}
if (status === 'PAUSED') {
return 'yellow';
}
if (status === 'ENDED' || status === 'CANCELED' || status === 'REMOVED') {
return 'red';
}
return 'gray';
};
export default function TicketDetails({
clients,
googleCompanySyncRunning,
}: Props) {
const [syncing, setSyncing] = React.useState(false);
// console.log(clients);
const [activeStatus, setActiveStatus] = React.useState<string | null>('all');
const campaignsData = clients ?? [];
const statusTabs = useMemo(() => {
const counts = campaignsData.reduce<Record<string, number>>((acc, client) => {
const status = client.status || 'UNKNOWN';
acc[status] = (acc[status] ?? 0) + 1;
return acc;
}, {});
return Object.entries(counts).sort(([a], [b]) => {
const aIndex = statusOrder.indexOf(a);
const bIndex = statusOrder.indexOf(b);
if (aIndex !== -1 || bIndex !== -1) {
return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex)
- (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex);
}
return a.localeCompare(b);
});
}, [campaignsData]);
const filteredClients = useMemo(() => {
if (!activeStatus || activeStatus === 'all') {
return campaignsData;
}
return campaignsData.filter((client) => (client.status || 'UNKNOWN') === activeStatus);
}, [activeStatus, campaignsData]);
const columns = useMemo(
() => [
{
@ -61,15 +109,7 @@ export default function TicketDetails({
header: 'Status',
Cell: ({ cell }: any) => {
const value = cell.getValue() as string;
const color =
value === 'ENABLED'
? 'green'
: value === 'PAUSED'
? 'yellow'
: value === 'ENDED'
? 'red'
: 'gray';
return <Badge color={color}>{value}</Badge>;
return <Badge color={getStatusColor(value)}>{value}</Badge>;
},
},
{
@ -142,10 +182,29 @@ export default function TicketDetails({
</Group>
<MantineReactTable
columns={columns}
data={clients}
data={filteredClients}
enableRowActions // ✅ REQUIRED
positionActionsColumn="last" // optional but recommended
renderRowActions={renderRowActions}
renderTopToolbarCustomActions={() => (
<Tabs value={activeStatus} onTabChange={setActiveStatus}>
<Tabs.List>
<Tabs.Tab value="all">All ({campaignsData.length})</Tabs.Tab>
{statusTabs.map(([status, count]) => (
<Tabs.Tab key={status} value={status}>
<Group spacing={6}>
<Badge color={getStatusColor(status)} variant="dot">
{status}
</Badge>
<Text size="sm" color="dimmed">
{count}
</Text>
</Group>
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
)}
/>
</Container>
</AppLayout>

View File

@ -30,13 +30,24 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
end_date: "",
amount: "",
management_fee: "",
management_fee_amount: "",
management_fee_tax: "",
media_fee: "",
media_fee_amount: "",
media_fee_tax: "",
tax_percent: "",
total_spending: "",
nett_amount: "",
});
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.data.nett_amount) {
form.setError("nett_amount", "Media Nett Amount is required.");
return;
}
const totalSpending = form.data.total_spending
? parseFloat(form.data.total_spending)
: null;
@ -51,9 +62,14 @@ export default function Page({ clientId, customerId, availableInvoices }: Props)
end_date: data.end_date || null,
amount: parseFloat(data.amount) || 0,
management_fee: parseFloat(data.management_fee) || 0,
management_fee_amount: data.management_fee_amount ? parseFloat(data.management_fee_amount) : null,
management_fee_tax: data.management_fee_tax ? parseFloat(data.management_fee_tax) : null,
media_fee: parseFloat(data.media_fee) || 0,
media_fee_amount: data.media_fee_amount ? parseFloat(data.media_fee_amount) : null,
media_fee_tax: data.media_fee_tax ? parseFloat(data.media_fee_tax) : null,
tax_percent: parseFloat(data.tax_percent) || 0,
total_spending: totalSpending,
nett_amount: parseFloat(form.data.nett_amount) || 0,
}));
form.post(route("client-invoices.store"));

View File

@ -30,10 +30,26 @@ export default function Page({ invoice, availableInvoices }: Props) {
invoice.management_fee !== null && invoice.management_fee !== undefined
? String(invoice.management_fee)
: "",
management_fee_amount:
invoice.management_fee_amount !== null && invoice.management_fee_amount !== undefined
? String(invoice.management_fee_amount)
: "",
management_fee_tax:
invoice.management_fee_tax !== null && invoice.management_fee_tax !== undefined
? String(invoice.management_fee_tax)
: "",
media_fee:
invoice.media_fee !== null && invoice.media_fee !== undefined
? String(invoice.media_fee)
: "",
media_fee_amount:
invoice.media_fee_amount !== null && invoice.media_fee_amount !== undefined
? String(invoice.media_fee_amount)
: "",
media_fee_tax:
invoice.media_fee_tax !== null && invoice.media_fee_tax !== undefined
? String(invoice.media_fee_tax)
: "",
tax_percent:
invoice.tax_percent !== null && invoice.tax_percent !== undefined
? String(invoice.tax_percent)
@ -44,11 +60,18 @@ export default function Page({ invoice, availableInvoices }: Props) {
: "",
client_id: invoice.client_id ? String(invoice.client_id) : undefined,
customer_id: invoice.client?.customer_id ?? "",
nett_amount: invoice.nett_amount !== null && invoice.nett_amount !== undefined ? String(invoice.nett_amount) : "",
});
const isApproved = !!invoice.approved_at;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form.data.nett_amount) {
form.setError("nett_amount", "Media Nett Amount is required.");
return;
}
const totalSpending = form.data.total_spending
? parseFloat(form.data.total_spending)
: null;
@ -66,6 +89,7 @@ export default function Page({ invoice, availableInvoices }: Props) {
media_fee: parseFloat(data.media_fee) || 0,
tax_percent: parseFloat(data.tax_percent) || 0,
total_spending: totalSpending,
nett_amount: parseFloat(form.data.nett_amount) || 0,
}));
form.put(route("client-invoices.update", { invoice: invoice.id }));
@ -91,11 +115,11 @@ export default function Page({ invoice, availableInvoices }: Props) {
</Badge>
</Group>
<Group gap="sm">
{!isApproved && (
{/* {!isApproved && (
<Button color="green" onClick={handleApprove}>
Approve invoice
</Button>
)}
)} */}
<Button
component={Link}
href={route("google-ads.accounts.show", { id: invoice.client?.customer_id ?? "" })}

View File

@ -151,13 +151,13 @@ export default function Dashboard({
icon: IconChecklist,
color: 'violet',
},
{
label: 'Missing SQL Code',
value: stats.clientsMissingSqlCode.toLocaleString('en-MY'),
detail: 'Synced clients not linked to SQL',
icon: IconLinkOff,
color: 'red',
},
// {
// label: 'Missing SQL Code',
// value: stats.clientsMissingSqlCode.toLocaleString('en-MY'),
// detail: 'Synced clients not linked to SQL',
// icon: IconLinkOff,
// color: 'red',
// },
];
return (
@ -191,7 +191,7 @@ export default function Dashboard({
</Group>
</Paper>
<SimpleGrid cols={4} breakpoints={[
<SimpleGrid cols={3} breakpoints={[
{ maxWidth: 'lg', cols: 2 },
{ maxWidth: 'md', cols: 2 },
{ maxWidth: 'xs', cols: 1 },

View File

@ -3,6 +3,7 @@ import { LucideIcon } from 'lucide-react';
export interface Auth {
user: User;
permissions?: Record<string, boolean> | null;
}
export interface BreadcrumbItem {
@ -83,7 +84,11 @@ export interface ClientInvoice {
amount: number;
total_spend?: number | string;
management_fee?: number;
management_fee_amount?: number;
management_fee_tax?: number;
media_fee?: number;
media_fee_amount?: number;
media_fee_tax?: number;
tax_percent?: number;
nett_amount?: number;
total_spending?: number | null;

View File

@ -16,7 +16,9 @@
})->name('home');
Route::middleware('auth')->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('dashboard', DashboardController::class)
->middleware('permission:dashboard.view')
->name('dashboard');
Route::prefix('google-ads')
->name('google-ads.')
@ -25,50 +27,65 @@
->name('accounts.')
->controller(GoogleAdsController::class)
->group(function () {
Route::get('/', 'accounts')->name('index');
Route::get('/', 'accounts')
->middleware('permission:google-ads.accounts.view')
->name('index');
Route::post('/sync-google-company-details', 'syncGoogleCompanyDetails')
->middleware('permission:google-ads.accounts.sync')
->name('sync-google-company-details');
Route::get('/{id}/edit', 'edit')->name('edit');
Route::get('/{id}', 'show')->name('show');
Route::post('/{id}/account', 'updateAccount')->name('account.update');
Route::get('/{id}/edit', 'edit')
->middleware('permission:google-ads.accounts.update')
->name('edit');
Route::get('/{id}', 'show')
->middleware('permission:google-ads.accounts.view')
->name('show');
Route::post('/{id}/account', 'updateAccount')
->middleware('permission:google-ads.accounts.update')
->name('account.update');
Route::prefix('activity')
->name('activity.')
->controller(ActivityController::class)
->group(function () {
Route::post('/{id}/store', 'storeActivity')
->middleware('permission:google-ads.activities.create')
->name('storeActivity');
Route::patch('/{id}/update', 'updateActivity')
->middleware('permission:google-ads.activities.update')
->name('updateActivity');
Route::patch('/{id}/complete', 'completeActivity')
->middleware('permission:google-ads.activities.complete')
->name('completeActivity');
Route::delete('/{id}/delete', 'deleteActivity')
->middleware('permission:google-ads.activities.delete')
->name('deleteActivity');
});
});
Route::get('import', [GoogleAdsController::class, 'insertCSVDataToDB'])->name('import');
Route::get('import', [GoogleAdsController::class, 'insertCSVDataToDB'])
->middleware('permission:google-ads.import')
->name('import');
});
Route::prefix('client-invoices')
->name('client-invoices.')
->controller(ClientInvoiceController::class)
->group(function () {
Route::get('/create', 'create')->name('create');
Route::post('/', 'store')->name('store');
Route::get('{invoice}/client/create', 'createClient')->name('client.create');
Route::post('{invoice}/client', 'storeClient')->name('client.store');
Route::get('{invoice}/edit', 'edit')->name('edit');
Route::put('{invoice}', 'update')->name('update');
Route::patch('{invoice}/approve', 'approve')->name('approve');
Route::delete('{invoice}', 'destroy')->name('destroy');
Route::get('/pdf/invoice/{id}', 'getPdfInvoice')->name('getPdfInvoice');
Route::get('/create', 'create')->middleware('permission:client-invoices.create')->name('create');
Route::post('/', 'store')->middleware('permission:client-invoices.create')->name('store');
Route::get('{invoice}/client/create', 'createClient')->middleware('permission:client-invoices.create-client')->name('client.create');
Route::post('{invoice}/client', 'storeClient')->middleware('permission:client-invoices.create-client')->name('client.store');
Route::get('{invoice}/edit', 'edit')->middleware('permission:client-invoices.update')->name('edit');
Route::put('{invoice}', 'update')->middleware('permission:client-invoices.update')->name('update');
Route::patch('{invoice}/approve', 'approve')->middleware('permission:client-invoices.approve')->name('approve');
Route::delete('{invoice}', 'destroy')->middleware('permission:client-invoices.delete')->name('destroy');
Route::get('/pdf/invoice/{id}', 'getPdfInvoice')->middleware('permission:client-invoices.view-pdf')->name('getPdfInvoice');
});
Route::prefix('clients')
->name('clients.')
->controller(ClientInvoiceAdjustmentController::class)
->group(function () {
Route::post('{client}/adjustments', 'store')->name('adjustments.store');
Route::delete('adjustments/{adjustment}', 'destroy')->name('adjustments.destroy');
Route::post('{client}/adjustments', 'store')->middleware('permission:clients.adjustments.create')->name('adjustments.store');
Route::delete('adjustments/{adjustment}', 'destroy')->middleware('permission:clients.adjustments.delete')->name('adjustments.destroy');
});
Route::prefix('google')
@ -76,6 +93,7 @@
->controller(GoogleController::class)
->group(function () {
Route::post('/getCampaignsDetails', 'listCampaignsMetrics')
->middleware('permission:google.reports.view')
->name('getCampaignsDetails');
});
@ -88,20 +106,20 @@
->group(function () {
// Route::post('/getCampaignsDetails', 'listCampaignsMetrics')
// ->name('getCampaignsDetails');
Route::get('/', 'index')->name('index');
Route::get('{id}/edit/', 'edit')->name('edit');
Route::post('{id}/update/', 'update')->name('update');
Route::get('/', 'index')->middleware('permission:management.roles.view')->name('index');
Route::get('{id}/edit/', 'edit')->middleware('permission:management.roles.update')->name('edit');
Route::post('{id}/update/', 'update')->middleware('permission:management.roles.update')->name('update');
});
Route::prefix('users')
->name('users.')
->controller(UserController::class)
->group(function () {
Route::get('/', 'index')->name('index');
Route::get('/create', 'create')->name('create');
Route::post('/store', 'store')->name('store');
Route::get('{id}/edit', 'edit')->name('edit');
Route::post('{id}/update', 'update')->name('update');
Route::get('/', 'index')->middleware('permission:management.users.view')->name('index');
Route::get('/create', 'create')->middleware('permission:management.users.create')->name('create');
Route::post('/store', 'store')->middleware('permission:management.users.create')->name('store');
Route::get('{id}/edit', 'edit')->middleware('permission:management.users.update')->name('edit');
Route::post('{id}/update', 'update')->middleware('permission:management.users.update')->name('update');
});
});
});