From 7b5247ab5043004f15526cd27c00fb22bf2dd66b Mon Sep 17 00:00:00 2001 From: brian-inspiren Date: Mon, 3 Aug 2026 03:46:30 +0800 Subject: [PATCH] feat: requested changes from HJ --- .../ClientInvoiceAdjustmentController.php | 44 +- .../Controllers/ClientInvoiceController.php | 164 ++++++-- app/Http/Controllers/GoogleAdsController.php | 5 +- app/Models/ClientInvoice.php | 1 + bootstrap/app.php | 10 +- ..._add_pdf_path_to_client_invoices_table.php | 32 ++ resources/js/app.tsx | 24 +- .../js/components/invoice-pdf-button.tsx | 198 +++++++++ resources/js/layouts/app-layout.tsx | 15 +- resources/js/pages/campaigns/show.tsx | 384 +++++++++++------- resources/js/pages/client-invoices/edit.tsx | 22 +- routes/web.php | 12 +- tests/Unit/PageExpiredResponseTest.php | 23 ++ 13 files changed, 697 insertions(+), 237 deletions(-) create mode 100644 database/migrations/2026_08_03_000001_add_pdf_path_to_client_invoices_table.php create mode 100644 resources/js/components/invoice-pdf-button.tsx create mode 100644 tests/Unit/PageExpiredResponseTest.php diff --git a/app/Http/Controllers/ClientInvoiceAdjustmentController.php b/app/Http/Controllers/ClientInvoiceAdjustmentController.php index faf0f02..a565647 100644 --- a/app/Http/Controllers/ClientInvoiceAdjustmentController.php +++ b/app/Http/Controllers/ClientInvoiceAdjustmentController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; -use App\Models\ClientInvoiceAdjustment; use App\Models\Client; +use App\Models\ClientInvoiceAdjustment; use App\Services\UserHierarchyService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -11,22 +11,13 @@ class ClientInvoiceAdjustmentController extends Controller { - public function __construct(private UserHierarchyService $hierarchyService) - { - } + public function __construct(private UserHierarchyService $hierarchyService) {} public function store(Request $request, Client $client) { abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403); - $validated = $request->validate([ - 'entry_type' => ['required', 'string', Rule::in([ - ClientInvoiceAdjustment::TYPE_DEBIT, - ClientInvoiceAdjustment::TYPE_CREDIT, - ])], - 'amount' => ['required', 'numeric', 'min:0'], - 'remark' => ['nullable', 'string'], - ]); + $validated = $request->validate($this->rules()); ClientInvoiceAdjustment::create([ 'client_id' => $client->id, @@ -40,6 +31,23 @@ public function store(Request $request, Client $client) ->with('message-info', 'Adjustment added successfully.'); } + public function update(Request $request, ClientInvoiceAdjustment $adjustment) + { + abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403); + + $validated = $request->validate($this->rules()); + + $adjustment->update([ + 'entry_type' => $validated['entry_type'], + 'amount' => $validated['amount'], + 'remark' => $validated['remark'] ?? null, + ]); + + return redirect() + ->route('google-ads.accounts.show', ['id' => $adjustment->client->customer_id]) + ->with('message-info', 'Adjustment updated successfully.'); + } + public function destroy(ClientInvoiceAdjustment $adjustment) { abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403); @@ -51,4 +59,16 @@ public function destroy(ClientInvoiceAdjustment $adjustment) ->route('google-ads.accounts.show', ['id' => $client->customer_id]) ->with('message-info', 'Adjustment deleted successfully.'); } + + private function rules(): array + { + return [ + 'entry_type' => ['required', 'string', Rule::in([ + ClientInvoiceAdjustment::TYPE_DEBIT, + ClientInvoiceAdjustment::TYPE_CREDIT, + ])], + 'amount' => ['required', 'numeric', 'min:0'], + 'remark' => ['nullable', 'string'], + ]; + } } diff --git a/app/Http/Controllers/ClientInvoiceController.php b/app/Http/Controllers/ClientInvoiceController.php index 699fb65..9a3519b 100644 --- a/app/Http/Controllers/ClientInvoiceController.php +++ b/app/Http/Controllers/ClientInvoiceController.php @@ -15,10 +15,11 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; -use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; +use Throwable; class ClientInvoiceController extends Controller { @@ -403,46 +404,141 @@ public function destroy(ClientInvoice $invoice) public function getPdfInvoice($id) { - $invoice = ClientInvoice::where('id', $id)->first(); - if (! empty($invoice) && ! empty($invoice->invoice_no)) { - if ($invoice->client !== null) { - abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); - } + $invoice = ClientInvoice::find($id); - $payload = [ - 'audience' => 'SEM', - 'invoice_numbers' => [$invoice->invoice_no], - ]; + abort_if($invoice === null, 404, 'Invoice not found.'); + + $this->authorizeInvoicePdfAccess($invoice); + + if ($this->storedInvoicePdfExists($invoice)) { + return response()->file( + Storage::disk('local')->path($invoice->pdf_path), + [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="invoice-'.$invoice->id.'.pdf"', + ], + ); + } + + $pdfUrl = $this->externalInvoicePdfUrl($invoice); + + abort_if($pdfUrl === null, 404, 'Invoice PDF not found.'); + + $response = Http::withHeaders([ + 'X-Secret' => config('app.billing_key'), + 'Accept' => 'application/json', + ])->get($pdfUrl); + + if ($response->status() === 404) { + abort(404, 'Invoice PDF not found.'); + } + + abort_unless($response->successful(), 502, 'Unable to fetch invoice PDF.'); + + return response()->stream( + function () use ($response) { + echo $response->body(); + }, + 200, + [ + 'Content-Type' => 'application/pdf', + ], + ); + } + + public function pdfInvoiceStatus(ClientInvoice $invoice) + { + $this->authorizeInvoicePdfAccess($invoice); + + if ($this->storedInvoicePdfExists($invoice)) { + return response()->json([ + 'available' => true, + 'source' => 'uploaded', + 'pdf_url' => route('client-invoices.getPdfInvoice', ['id' => $invoice->id]), + ]); + } + + if ($this->externalInvoicePdfUrl($invoice) !== null) { + return response()->json([ + 'available' => true, + 'source' => 'external', + 'pdf_url' => route('client-invoices.getPdfInvoice', ['id' => $invoice->id]), + ]); + } + + return response()->json([ + 'available' => false, + 'message' => 'Invoice PDF not found.', + ], 404); + } + + public function uploadPdfInvoice(Request $request, ClientInvoice $invoice) + { + $this->authorizeInvoicePdfAccess($invoice); + + $validated = $request->validate([ + 'invoice_pdf' => ['required', 'file', 'mimes:pdf', 'max:20480'], + ]); + + if ($this->storedInvoicePdfExists($invoice)) { + Storage::disk('local')->delete($invoice->pdf_path); + } + + $path = $validated['invoice_pdf']->store('client-invoices/'.$invoice->id, 'local'); + + $invoice->update([ + 'pdf_path' => $path, + ]); + + return response()->json([ + 'message' => 'Invoice uploaded successfully.', + 'pdf_url' => route('client-invoices.getPdfInvoice', ['id' => $invoice->id]), + ]); + } + + private function authorizeInvoicePdfAccess(ClientInvoice $invoice): void + { + $invoice->loadMissing('client'); + + if ($invoice->client !== null) { + abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403); + } + } + + private function storedInvoicePdfExists(ClientInvoice $invoice): bool + { + return ! empty($invoice->pdf_path) + && Storage::disk('local')->exists($invoice->pdf_path); + } + + private function externalInvoicePdfUrl(ClientInvoice $invoice): ?string + { + if (empty($invoice->invoice_no)) { + return null; + } + + try { $invoiceResponse = Http::acceptJson() ->withHeaders([ 'X-Secret' => config('app.billing_key'), 'Accept' => 'application/json', ]) - ->get(config('app.billing_url').'/customer/invoices/', $payload); - - if ($invoiceResponse->successful()) { - $invoiceDetails = json_decode($invoiceResponse->body()); - $response = Http::withHeaders([ - 'X-Secret' => config('app.billing_key'), - 'Accept' => 'application/json', - ])->get($invoiceDetails->data[0]->pdf_url); - if ($response->successful()) { - return response()->stream( - function () use ($response) { - echo $response->body(); - }, - 200, - [ - 'Content-Type' => 'application/pdf', - ] - ); - } else { - $response->throw(); - } - } else { - $invoiceResponse->throw(); - } + ->get(config('app.billing_url').'/customer/invoices/', [ + 'audience' => 'SEM', + 'invoice_numbers' => [$invoice->invoice_no], + ]); + } catch (Throwable) { + return null; } + + if (! $invoiceResponse->successful()) { + return null; + } + + $invoiceDetails = $invoiceResponse->json(); + $pdfUrl = data_get($invoiceDetails, 'data.0.pdf_url'); + + return is_string($pdfUrl) && $pdfUrl !== '' ? $pdfUrl : null; } public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\RedirectResponse diff --git a/app/Http/Controllers/GoogleAdsController.php b/app/Http/Controllers/GoogleAdsController.php index ab5e99f..a587bfb 100644 --- a/app/Http/Controllers/GoogleAdsController.php +++ b/app/Http/Controllers/GoogleAdsController.php @@ -113,7 +113,7 @@ public function show($id) $this->hydrateClient($account); $account = array_merge($account, [ 'industry' => $localClient->industry, - 'sql_acc_code' => $localClient->sql_acc_code, + 'sql_acc_code' => $localClient->customers->pluck('sql_acc_code')->implode(', '), 'activities_list' => $activityList, ]); @@ -242,8 +242,7 @@ private function hydrateClient(array $account): array ] ); // dd($localClient); - $localClient->load(['assignations.user', 'invoices.payments.items.billingItemType']); - + $localClient->load(['assignations.user', 'invoices.payments.items.billingItemType','customers']); $assignments = $localClient->assignations ->mapWithKeys(function (ClientUserAssignation $assignation) { return [$assignation->role => $assignation->user_id]; diff --git a/app/Models/ClientInvoice.php b/app/Models/ClientInvoice.php index c2c7096..6780d13 100644 --- a/app/Models/ClientInvoice.php +++ b/app/Models/ClientInvoice.php @@ -16,6 +16,7 @@ class ClientInvoice extends Model 'pending_sql_acc_code', 'pending_client_name', 'invoice_no', + 'pdf_path', 'linked_invoice_id', 'approved_at', 'total_sem_amount', diff --git a/bootstrap/app.php b/bootstrap/app.php index a46239b..439265e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,8 +6,10 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; +use Illuminate\Http\Request; use Spatie\Permission\Middleware\PermissionMiddleware; use Spatie\Permission\Middleware\RoleMiddleware; +use Symfony\Component\HttpFoundation\Response; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -31,5 +33,11 @@ ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->respond(function (Response $response, Throwable $exception, Request $request) { + if ($response->getStatusCode() === 419 && ! $request->expectsJson()) { + return back()->with('message-warning', 'Your session expired. Please try again.'); + } + + return $response; + }); })->create(); diff --git a/database/migrations/2026_08_03_000001_add_pdf_path_to_client_invoices_table.php b/database/migrations/2026_08_03_000001_add_pdf_path_to_client_invoices_table.php new file mode 100644 index 0000000..d19a85b --- /dev/null +++ b/database/migrations/2026_08_03_000001_add_pdf_path_to_client_invoices_table.php @@ -0,0 +1,32 @@ +string('pdf_path')->nullable()->after('invoice_no'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('client_invoices', 'pdf_path')) { + Schema::table('client_invoices', function (Blueprint $table) { + $table->dropColumn('pdf_path'); + }); + } + } +}; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 684727a..86bb983 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -1,27 +1,25 @@ -import '../css/app.css'; -import axios from 'axios'; +import { initializeTheme } from '@/hooks/use-appearance'; import { createInertiaApp } from '@inertiajs/react'; +import axios from 'axios'; +import 'dayjs/locale/en-sg'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { StrictMode, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import 'dayjs/locale/en-sg'; -import { initializeTheme } from '@/hooks/use-appearance'; +import '../css/app.css'; axios.defaults.withCredentials = true; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; -const csrfToken = document.head?.querySelector('meta[name="csrf-token"]'); -if (csrfToken instanceof HTMLMetaElement && csrfToken.content) { - axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken.content; -} +axios.defaults.xsrfCookieName = 'XSRF-TOKEN'; +axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN'; // Mantine imports import { - MantineProvider, ColorSchemeProvider, + MantineProvider, type ColorScheme, } from '@mantine/core'; -import { Notifications } from '@mantine/notifications'; import { ModalsProvider } from '@mantine/modals'; +import { Notifications } from '@mantine/notifications'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; @@ -67,14 +65,14 @@ createInertiaApp({ function Main() { const [colorScheme, setColorScheme] = useState(() => - getInitialScheme() + getInitialScheme(), ); useEffect(() => { try { document.documentElement.classList.toggle( 'dark', - colorScheme === 'dark' + colorScheme === 'dark', ); document.documentElement.style.colorScheme = colorScheme; window.localStorage.setItem('appearance', colorScheme); @@ -84,7 +82,7 @@ createInertiaApp({ const toggleColorScheme = (value?: ColorScheme) => setColorScheme( - value ?? (colorScheme === 'light' ? 'dark' : 'light') + value ?? (colorScheme === 'light' ? 'dark' : 'light'), ); return ( diff --git a/resources/js/components/invoice-pdf-button.tsx b/resources/js/components/invoice-pdf-button.tsx new file mode 100644 index 0000000..a1c0496 --- /dev/null +++ b/resources/js/components/invoice-pdf-button.tsx @@ -0,0 +1,198 @@ +import { + ActionIcon, + Button, + FileInput, + Group, + Modal, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconFileDollar, IconUpload } from '@tabler/icons-react'; +import axios from 'axios'; +import { useMemo, useState } from 'react'; + +type InvoicePdfButtonProps = { + invoiceId: number; + invoiceNo?: string | null; + display?: 'icon' | 'button'; + label?: string; +}; + +type InvoicePdfUploadResponse = { + message?: string; + pdf_url?: string; +}; + +export default function InvoicePdfButton({ + invoiceId, + invoiceNo, + display = 'icon', + label = 'View Invoice', +}: InvoicePdfButtonProps) { + const [opened, setOpened] = useState(false); + const [file, setFile] = useState(null); + const [checking, setChecking] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + const urls = useMemo( + () => ({ + view: route('client-invoices.getPdfInvoice', { id: invoiceId }), + upload: route('client-invoices.pdf.upload', { + invoice: invoiceId, + }), + }), + [invoiceId], + ); + + const openInvoice = (url = urls.view) => { + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + const handleView = async () => { + setChecking(true); + setError(null); + + try { + const response = await axios.get(urls.view, { + responseType: 'blob', + validateStatus: (status) => + (status >= 200 && status < 300) || status === 404, + }); + + if (response.status === 404) { + setOpened(true); + return; + } + + const blobUrl = URL.createObjectURL(response.data); + openInvoice(blobUrl); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + } catch { + notifications.show({ + title: 'Unable to open invoice', + message: 'Please try again later.', + color: 'red', + }); + } finally { + setChecking(false); + } + }; + + const handleUpload = async () => { + if (!file) { + setError('Select a PDF invoice to upload.'); + return; + } + + setUploading(true); + setError(null); + + const formData = new FormData(); + formData.append('invoice_pdf', file); + + try { + const response = await axios.post( + urls.upload, + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + }, + ); + + notifications.show({ + title: 'Invoice uploaded', + message: + response.data.message ?? 'Invoice uploaded successfully.', + color: 'green', + }); + + setOpened(false); + setFile(null); + } catch (caught) { + if (axios.isAxiosError(caught) && caught.response?.status === 422) { + setError('Upload a valid PDF file up to 20 MB.'); + return; + } + + setError('The invoice could not be uploaded. Please try again.'); + } finally { + setUploading(false); + } + }; + + const button = + display === 'button' ? ( + + ) : ( + + + + + + ); + + return ( + <> + {button} + + setOpened(false)} + title="Upload invoice" + centered + > + + + {invoiceNo + ? `Invoice ${invoiceNo} is not available yet. Upload the PDF to view it.` + : 'This invoice PDF is not available yet. Upload the PDF to view it.'} + + + } + value={file} + onChange={(value) => { + setFile(value); + setError(null); + }} + error={error} + /> + + + + + + + + + ); +} diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 1ed9267..25dcab5 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -23,6 +23,7 @@ import { } from '@mantine/core'; import React from 'react'; +import InvoicePdfButton from '@/components/invoice-pdf-button'; import { SidebarProvider } from '@/components/ui/sidebar'; import { logout } from '@/routes'; import { edit } from '@/routes/profile'; @@ -37,7 +38,6 @@ import { IconAlertCircle, IconBell, IconCircleX, - IconFileDollar, IconInfoCircle, IconLogout, IconSettings, @@ -847,15 +847,10 @@ function AppNotifications() { ) : null} - - - - - + ); diff --git a/resources/js/pages/campaigns/show.tsx b/resources/js/pages/campaigns/show.tsx index 6ab94e7..0ca8059 100644 --- a/resources/js/pages/campaigns/show.tsx +++ b/resources/js/pages/campaigns/show.tsx @@ -1,3 +1,4 @@ +import InvoicePdfButton from '@/components/invoice-pdf-button'; import Table from '@/components/table'; import ActivityForm from '@/forms/activities/activityForm'; import { FormStatus } from '@/types'; @@ -7,7 +8,7 @@ import { useDisclosure } from '@mantine/hooks'; import { useModals } from '@mantine/modals'; import axios from 'axios'; import dayjs from 'dayjs'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import AppLayout from '../../layouts/app-layout'; import { Client, @@ -55,7 +56,6 @@ import { IconDeviceTv, IconEdit, IconEye, - IconFileDollar, IconLink, IconNotebook, IconPlus, @@ -171,6 +171,12 @@ type AdjustmentRow = { created_at?: string | null; }; +type AdjustmentFormValues = { + entry_type: 'debit' | 'credit'; + amount: number | ''; + remark: string; +}; + type InvoiceRow = ClientInvoice & { row_kind: 'invoice'; linked_invoices?: InvoiceRow[]; @@ -331,7 +337,7 @@ const getPaymentTaxAmount = (payment: ClientInvoicePayment): number => { Math.max( 0, parseNumber(item.payment_item_amount) - - parseNumber(item.net_amount), + parseNumber(item.net_amount), ), 0, ); @@ -343,7 +349,7 @@ const getPaymentTaxAmount = (payment: ClientInvoicePayment): number => { return Math.max( 0, parseNumber(payment.payment_total_amount) - - parseNumber(payment.payment_nett_amount), + parseNumber(payment.payment_nett_amount), ); }; @@ -395,6 +401,9 @@ const getPaymentNetAmount = (payment: ClientInvoicePayment): number => const formatCurrency = (value?: number | string | null): string => currencyFormatter.format(parseNumber(value)); +const normalizeCurrencyValue = (value: number): number => + Math.abs(value) < 0.005 ? 0 : value; + const formatPercent = (value?: number | string | null): string => `${currencyFormatter.format(parseNumber(value))}%`; @@ -488,6 +497,7 @@ export default function TicketDetails({ dayjs().endOf('month').toDate(), ]); const [loading, setLoading] = useState(false); + const [pageActionLoading, setPageActionLoading] = useState(false); const [campaigns, setCampaigns] = useState([]); const [metrics, setMetrics] = useState(summaryDefaults); const [description, setDescription] = useState(''); @@ -523,48 +533,17 @@ export default function TicketDetails({ const modals = useModals(); const [adjustmentModalOpened, setAdjustmentModalOpened] = useState(false); - const [adjustmentEntryType, setAdjustmentEntryType] = useState< - 'debit' | 'credit' - >('debit'); - const [adjustmentAmount, setAdjustmentAmount] = useState(''); - const [adjustmentRemark, setAdjustmentRemark] = useState(''); + const [selectedAdjustment, setSelectedAdjustment] = + useState(null); - const openAdjustmentModal = () => { - setAdjustmentEntryType('debit'); - setAdjustmentAmount(''); - setAdjustmentRemark(''); + const openAdjustmentModal = (adjustment?: AdjustmentRow) => { + setSelectedAdjustment(adjustment ?? null); setAdjustmentModalOpened(true); }; - const closeAdjustmentModal = () => setAdjustmentModalOpened(false); - - const refreshAccount = () => { - router.visit(route('google-ads.accounts.show', { id }), { - preserveScroll: true, - preserveState: false, - replace: true, - }); - }; - - const submitAdjustment = () => { - router.post( - route('clients.adjustments.store', { client: localClientId }), - { - entry_type: adjustmentEntryType, - amount: - typeof adjustmentAmount === 'number' ? adjustmentAmount : 0, - remark: adjustmentRemark.trim() - ? adjustmentRemark.trim() - : null, - }, - { - preserveScroll: true, - onSuccess: () => { - closeAdjustmentModal(); - refreshAccount(); - }, - }, - ); + const closeAdjustmentModal = () => { + setAdjustmentModalOpened(false); + setSelectedAdjustment(null); }; const activities = useMemo( @@ -671,7 +650,7 @@ export default function TicketDetails({ ), total_actual_spend: String( campaign.total_actual_spend ?? - summaryDefaults.total_actual_spend, + summaryDefaults.total_actual_spend, ), metrics: normalizeCampaignMetrics(campaign.metrics), }), @@ -701,7 +680,9 @@ export default function TicketDetails({ Inertia.delete( route('client-invoices.destroy', { invoice: invoiceId }), { - preserveState: true, + preserveState: false, + onStart: () => setPageActionLoading(true), + onFinish: () => setPageActionLoading(false), }, ); }; @@ -718,10 +699,9 @@ export default function TicketDetails({ Inertia.delete( route('clients.adjustments.destroy', { adjustment: adjustmentId }), { - preserveState: true, - onSuccess: () => { - refreshAccount(); - }, + preserveState: false, + onStart: () => setPageActionLoading(true), + onFinish: () => setPageActionLoading(false), }, ); }; @@ -735,17 +715,10 @@ export default function TicketDetails({ return ( {item.invoice_no !== null && ( - - - - - + )} ; }) => ( + openAdjustmentModal(row.original)} + > + + {value}; }, }, @@ -824,22 +804,26 @@ export default function TicketDetails({ (sum, invoice) => sum + getCreditCardMediaSpending(invoice), 0, ); - const billableInvoiceSpending = Math.max( - 0, - parseNumber(lifeTimeSpending) - creditCardMediaSpending, - ); - const nettAmountBase = invoicesData.reduce( (sum, invoice) => sum + getBillableNettAmount(invoice), 0, ); - - const nettAmount = nettAmountBase + adjustmentNet; - const remainingAmount = getInvoicesMediaItemsAreAllCreditCard( - invoicesData, - ) + const shouldZeroSpendingAndRemaining = + getInvoicesMediaItemsAreAllCreditCard(invoicesData) || + normalizeCurrencyValue(nettAmountBase) === 0; + const billableInvoiceSpending = shouldZeroSpendingAndRemaining ? 0 - : Math.max(0, nettAmount - billableInvoiceSpending); + : Math.max( + 0, + parseNumber(lifeTimeSpending) - creditCardMediaSpending, + ); + + const adjustedNettAmount = nettAmountBase + adjustmentNet; + const remainingAmount = shouldZeroSpendingAndRemaining + ? 0 + : normalizeCurrencyValue( + adjustedNettAmount - billableInvoiceSpending, + ); return { managementFee: invoicesData.reduce( @@ -853,7 +837,7 @@ export default function TicketDetails({ invoiceSpending, billableInvoiceSpending, adjustmentNet, - nettAmount, + nettAmount: nettAmountBase, remainingAmount, }; }, [clientInvoices, clientAdjustments, lifeTimeSpending]); @@ -895,7 +879,7 @@ export default function TicketDetails({ color: 'green', }, { - label: 'Spending Media Fee (Google Live)', + label: 'Spending Media Fee (Live)', value: invoiceTotals.billableInvoiceSpending, icon: IconCurrencyDollar, color: 'indigo', @@ -905,6 +889,8 @@ export default function TicketDetails({ value: invoiceTotals.remainingAmount, icon: IconChartBar, color: 'teal', + valueColor: + invoiceTotals.remainingAmount < 0 ? 'red' : 'green', }, { label: 'Adjustments', @@ -1121,8 +1107,8 @@ export default function TicketDetails({ accessorFn: (activity) => activity.estimated_completed_at ? dayjs(activity.estimated_completed_at) - .startOf('day') - .toDate() + .startOf('day') + .toDate() : null, Cell: ({ cell }) => { const date = cell.getValue(); @@ -1199,8 +1185,8 @@ export default function TicketDetails({ accessorFn: (activity) => activity.estimated_completed_at ? dayjs(activity.estimated_completed_at) - .startOf('day') - .toDate() + .startOf('day') + .toDate() : null, Cell: ({ cell }) => { const date = cell.getValue(); @@ -1345,8 +1331,8 @@ export default function TicketDetails({ ) => ( {allowComplete && - !activity.completed_at && - activity.estimated_completed_at ? ( + !activity.completed_at && + activity.estimated_completed_at ? ( completedButtonClicked(activity)} @@ -1418,6 +1404,8 @@ export default function TicketDetails({ { method: 'patch', data: { status }, + onStart: () => setPageActionLoading(true), + onFinish: () => setPageActionLoading(false), }, ); }, @@ -1438,6 +1426,8 @@ export default function TicketDetails({ }), { method: 'delete', + onStart: () => setPageActionLoading(true), + onFinish: () => setPageActionLoading(false), }, ); }, @@ -1484,7 +1474,7 @@ export default function TicketDetails({ - + RM{' '} {item.value.toLocaleString( 'en-MY', @@ -1783,65 +1777,13 @@ export default function TicketDetails({ // enableRowActions /> - - -