feat: requested changes from HJ

This commit is contained in:
brian-inspiren 2026-08-03 03:46:30 +08:00
parent 027f7845b3
commit 7b5247ab50
13 changed files with 697 additions and 237 deletions

View File

@ -2,8 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\ClientInvoiceAdjustment;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientInvoiceAdjustment;
use App\Services\UserHierarchyService; use App\Services\UserHierarchyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -11,22 +11,13 @@
class ClientInvoiceAdjustmentController extends Controller class ClientInvoiceAdjustmentController extends Controller
{ {
public function __construct(private UserHierarchyService $hierarchyService) public function __construct(private UserHierarchyService $hierarchyService) {}
{
}
public function store(Request $request, Client $client) public function store(Request $request, Client $client)
{ {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403); abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$validated = $request->validate([ $validated = $request->validate($this->rules());
'entry_type' => ['required', 'string', Rule::in([
ClientInvoiceAdjustment::TYPE_DEBIT,
ClientInvoiceAdjustment::TYPE_CREDIT,
])],
'amount' => ['required', 'numeric', 'min:0'],
'remark' => ['nullable', 'string'],
]);
ClientInvoiceAdjustment::create([ ClientInvoiceAdjustment::create([
'client_id' => $client->id, 'client_id' => $client->id,
@ -40,6 +31,23 @@ public function store(Request $request, Client $client)
->with('message-info', 'Adjustment added successfully.'); ->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) public function destroy(ClientInvoiceAdjustment $adjustment)
{ {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403); 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]) ->route('google-ads.accounts.show', ['id' => $client->customer_id])
->with('message-info', 'Adjustment deleted successfully.'); ->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'],
];
}
} }

View File

@ -15,10 +15,11 @@
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Throwable;
class ClientInvoiceController extends Controller class ClientInvoiceController extends Controller
{ {
@ -403,30 +404,37 @@ public function destroy(ClientInvoice $invoice)
public function getPdfInvoice($id) public function getPdfInvoice($id)
{ {
$invoice = ClientInvoice::where('id', $id)->first(); $invoice = ClientInvoice::find($id);
if (! empty($invoice) && ! empty($invoice->invoice_no)) {
if ($invoice->client !== null) { abort_if($invoice === null, 404, 'Invoice not found.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$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"',
],
);
} }
$payload = [ $pdfUrl = $this->externalInvoicePdfUrl($invoice);
'audience' => 'SEM',
'invoice_numbers' => [$invoice->invoice_no], abort_if($pdfUrl === null, 404, 'Invoice PDF not found.');
];
$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([ $response = Http::withHeaders([
'X-Secret' => config('app.billing_key'), 'X-Secret' => config('app.billing_key'),
'Accept' => 'application/json', 'Accept' => 'application/json',
])->get($invoiceDetails->data[0]->pdf_url); ])->get($pdfUrl);
if ($response->successful()) {
if ($response->status() === 404) {
abort(404, 'Invoice PDF not found.');
}
abort_unless($response->successful(), 502, 'Unable to fetch invoice PDF.');
return response()->stream( return response()->stream(
function () use ($response) { function () use ($response) {
echo $response->body(); echo $response->body();
@ -434,15 +442,103 @@ function () use ($response) {
200, 200,
[ [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
] ],
); );
} else {
$response->throw();
} }
} else {
$invoiceResponse->throw(); 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/', [
'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 public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\RedirectResponse

View File

@ -113,7 +113,7 @@ public function show($id)
$this->hydrateClient($account); $this->hydrateClient($account);
$account = array_merge($account, [ $account = array_merge($account, [
'industry' => $localClient->industry, 'industry' => $localClient->industry,
'sql_acc_code' => $localClient->sql_acc_code, 'sql_acc_code' => $localClient->customers->pluck('sql_acc_code')->implode(', '),
'activities_list' => $activityList, 'activities_list' => $activityList,
]); ]);
@ -242,8 +242,7 @@ private function hydrateClient(array $account): array
] ]
); );
// dd($localClient); // dd($localClient);
$localClient->load(['assignations.user', 'invoices.payments.items.billingItemType']); $localClient->load(['assignations.user', 'invoices.payments.items.billingItemType','customers']);
$assignments = $localClient->assignations $assignments = $localClient->assignations
->mapWithKeys(function (ClientUserAssignation $assignation) { ->mapWithKeys(function (ClientUserAssignation $assignation) {
return [$assignation->role => $assignation->user_id]; return [$assignation->role => $assignation->user_id];

View File

@ -16,6 +16,7 @@ class ClientInvoice extends Model
'pending_sql_acc_code', 'pending_sql_acc_code',
'pending_client_name', 'pending_client_name',
'invoice_no', 'invoice_no',
'pdf_path',
'linked_invoice_id', 'linked_invoice_id',
'approved_at', 'approved_at',
'total_sem_amount', 'total_sem_amount',

View File

@ -6,8 +6,10 @@
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Spatie\Permission\Middleware\PermissionMiddleware; use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware; use Spatie\Permission\Middleware\RoleMiddleware;
use Symfony\Component\HttpFoundation\Response;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@ -31,5 +33,11 @@
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->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(); })->create();

View File

@ -0,0 +1,32 @@
<?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', 'pdf_path')) {
Schema::table('client_invoices', function (Blueprint $table) {
$table->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');
});
}
}
};

View File

@ -1,27 +1,25 @@
import '../css/app.css'; import { initializeTheme } from '@/hooks/use-appearance';
import axios from 'axios';
import { createInertiaApp } from '@inertiajs/react'; import { createInertiaApp } from '@inertiajs/react';
import axios from 'axios';
import 'dayjs/locale/en-sg';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { StrictMode, useEffect, useState } from 'react'; import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import 'dayjs/locale/en-sg'; import '../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const csrfToken = document.head?.querySelector('meta[name="csrf-token"]'); axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
if (csrfToken instanceof HTMLMetaElement && csrfToken.content) { axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken.content;
}
// Mantine imports // Mantine imports
import { import {
MantineProvider,
ColorSchemeProvider, ColorSchemeProvider,
MantineProvider,
type ColorScheme, type ColorScheme,
} from '@mantine/core'; } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@ -67,14 +65,14 @@ createInertiaApp({
function Main() { function Main() {
const [colorScheme, setColorScheme] = useState<ColorScheme>(() => const [colorScheme, setColorScheme] = useState<ColorScheme>(() =>
getInitialScheme() getInitialScheme(),
); );
useEffect(() => { useEffect(() => {
try { try {
document.documentElement.classList.toggle( document.documentElement.classList.toggle(
'dark', 'dark',
colorScheme === 'dark' colorScheme === 'dark',
); );
document.documentElement.style.colorScheme = colorScheme; document.documentElement.style.colorScheme = colorScheme;
window.localStorage.setItem('appearance', colorScheme); window.localStorage.setItem('appearance', colorScheme);
@ -84,7 +82,7 @@ createInertiaApp({
const toggleColorScheme = (value?: ColorScheme) => const toggleColorScheme = (value?: ColorScheme) =>
setColorScheme( setColorScheme(
value ?? (colorScheme === 'light' ? 'dark' : 'light') value ?? (colorScheme === 'light' ? 'dark' : 'light'),
); );
return ( return (

View File

@ -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<File | null>(null);
const [checking, setChecking] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(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<Blob>(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<InvoicePdfUploadResponse>(
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' ? (
<Button
leftIcon={<IconFileDollar size={16} />}
variant="light"
color="green"
loading={checking}
onClick={handleView}
>
{label}
</Button>
) : (
<Tooltip label={label} withArrow withinPortal>
<ActionIcon
aria-label={label}
disabled={checking}
onClick={handleView}
>
<IconFileDollar color="green" />
</ActionIcon>
</Tooltip>
);
return (
<>
{button}
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Upload invoice"
centered
>
<Stack spacing="md">
<Text size="sm" color="dimmed">
{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.'}
</Text>
<FileInput
label="Invoice PDF"
accept="application/pdf"
icon={<IconUpload size={16} />}
value={file}
onChange={(value) => {
setFile(value);
setError(null);
}}
error={error}
/>
<Group position="right" spacing="sm">
<Button
variant="subtle"
onClick={() => setOpened(false)}
disabled={uploading}
>
Cancel
</Button>
<Button
leftIcon={<IconUpload size={16} />}
onClick={handleUpload}
loading={uploading}
>
Upload
</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@ -23,6 +23,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import React from 'react'; import React from 'react';
import InvoicePdfButton from '@/components/invoice-pdf-button';
import { SidebarProvider } from '@/components/ui/sidebar'; import { SidebarProvider } from '@/components/ui/sidebar';
import { logout } from '@/routes'; import { logout } from '@/routes';
import { edit } from '@/routes/profile'; import { edit } from '@/routes/profile';
@ -37,7 +38,6 @@ import {
IconAlertCircle, IconAlertCircle,
IconBell, IconBell,
IconCircleX, IconCircleX,
IconFileDollar,
IconInfoCircle, IconInfoCircle,
IconLogout, IconLogout,
IconSettings, IconSettings,
@ -847,15 +847,10 @@ function AppNotifications() {
</Tooltip> </Tooltip>
</ActionIcon> </ActionIcon>
) : null} ) : null}
<ActionIcon <InvoicePdfButton
component="a" invoiceId={row.original.id}
href={`/client-invoices/pdf/invoice/${row.original.id}`} invoiceNo={row.original.invoice_no}
target="_blank" />
>
<Tooltip label="View Invoice" withArrow withinPortal>
<IconFileDollar color="green" />
</Tooltip>
</ActionIcon>
</Group> </Group>
); );

View File

@ -1,3 +1,4 @@
import InvoicePdfButton from '@/components/invoice-pdf-button';
import Table from '@/components/table'; import Table from '@/components/table';
import ActivityForm from '@/forms/activities/activityForm'; import ActivityForm from '@/forms/activities/activityForm';
import { FormStatus } from '@/types'; import { FormStatus } from '@/types';
@ -7,7 +8,7 @@ import { useDisclosure } from '@mantine/hooks';
import { useModals } from '@mantine/modals'; import { useModals } from '@mantine/modals';
import axios from 'axios'; import axios from 'axios';
import dayjs from 'dayjs'; 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 AppLayout from '../../layouts/app-layout';
import { import {
Client, Client,
@ -55,7 +56,6 @@ import {
IconDeviceTv, IconDeviceTv,
IconEdit, IconEdit,
IconEye, IconEye,
IconFileDollar,
IconLink, IconLink,
IconNotebook, IconNotebook,
IconPlus, IconPlus,
@ -171,6 +171,12 @@ type AdjustmentRow = {
created_at?: string | null; created_at?: string | null;
}; };
type AdjustmentFormValues = {
entry_type: 'debit' | 'credit';
amount: number | '';
remark: string;
};
type InvoiceRow = ClientInvoice & { type InvoiceRow = ClientInvoice & {
row_kind: 'invoice'; row_kind: 'invoice';
linked_invoices?: InvoiceRow[]; linked_invoices?: InvoiceRow[];
@ -395,6 +401,9 @@ const getPaymentNetAmount = (payment: ClientInvoicePayment): number =>
const formatCurrency = (value?: number | string | null): string => const formatCurrency = (value?: number | string | null): string =>
currencyFormatter.format(parseNumber(value)); currencyFormatter.format(parseNumber(value));
const normalizeCurrencyValue = (value: number): number =>
Math.abs(value) < 0.005 ? 0 : value;
const formatPercent = (value?: number | string | null): string => const formatPercent = (value?: number | string | null): string =>
`${currencyFormatter.format(parseNumber(value))}%`; `${currencyFormatter.format(parseNumber(value))}%`;
@ -488,6 +497,7 @@ export default function TicketDetails({
dayjs().endOf('month').toDate(), dayjs().endOf('month').toDate(),
]); ]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageActionLoading, setPageActionLoading] = useState(false);
const [campaigns, setCampaigns] = useState<CampaignDetail[]>([]); const [campaigns, setCampaigns] = useState<CampaignDetail[]>([]);
const [metrics, setMetrics] = useState<CampaignSummary>(summaryDefaults); const [metrics, setMetrics] = useState<CampaignSummary>(summaryDefaults);
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
@ -523,48 +533,17 @@ export default function TicketDetails({
const modals = useModals(); const modals = useModals();
const [adjustmentModalOpened, setAdjustmentModalOpened] = useState(false); const [adjustmentModalOpened, setAdjustmentModalOpened] = useState(false);
const [adjustmentEntryType, setAdjustmentEntryType] = useState< const [selectedAdjustment, setSelectedAdjustment] =
'debit' | 'credit' useState<AdjustmentRow | null>(null);
>('debit');
const [adjustmentAmount, setAdjustmentAmount] = useState<number | ''>('');
const [adjustmentRemark, setAdjustmentRemark] = useState('');
const openAdjustmentModal = () => { const openAdjustmentModal = (adjustment?: AdjustmentRow) => {
setAdjustmentEntryType('debit'); setSelectedAdjustment(adjustment ?? null);
setAdjustmentAmount('');
setAdjustmentRemark('');
setAdjustmentModalOpened(true); setAdjustmentModalOpened(true);
}; };
const closeAdjustmentModal = () => setAdjustmentModalOpened(false); const closeAdjustmentModal = () => {
setAdjustmentModalOpened(false);
const refreshAccount = () => { setSelectedAdjustment(null);
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 activities = useMemo( const activities = useMemo(
@ -701,7 +680,9 @@ export default function TicketDetails({
Inertia.delete( Inertia.delete(
route('client-invoices.destroy', { invoice: invoiceId }), 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( Inertia.delete(
route('clients.adjustments.destroy', { adjustment: adjustmentId }), route('clients.adjustments.destroy', { adjustment: adjustmentId }),
{ {
preserveState: true, preserveState: false,
onSuccess: () => { onStart: () => setPageActionLoading(true),
refreshAccount(); onFinish: () => setPageActionLoading(false),
},
}, },
); );
}; };
@ -735,17 +715,10 @@ export default function TicketDetails({
return ( return (
<Group spacing="xs"> <Group spacing="xs">
{item.invoice_no !== null && ( {item.invoice_no !== null && (
<ActionIcon <InvoicePdfButton
component="a" invoiceId={item.id}
href={route('client-invoices.getPdfInvoice', { invoiceNo={item.invoice_no}
id: item.id, />
})}
target="_blank"
>
<Tooltip label={'View Invoice'} withArrow withinPortal>
<IconFileDollar color="green" />
</Tooltip>
</ActionIcon>
)} )}
<ActionIcon <ActionIcon
component={Link} component={Link}
@ -772,6 +745,13 @@ export default function TicketDetails({
row: MRT_Row<AdjustmentRow>; row: MRT_Row<AdjustmentRow>;
}) => ( }) => (
<Group spacing="xs"> <Group spacing="xs">
<ActionIcon
color="blue"
variant="light"
onClick={() => openAdjustmentModal(row.original)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon <ActionIcon
color="red" color="red"
variant="light" variant="light"
@ -824,22 +804,26 @@ export default function TicketDetails({
(sum, invoice) => sum + getCreditCardMediaSpending(invoice), (sum, invoice) => sum + getCreditCardMediaSpending(invoice),
0, 0,
); );
const billableInvoiceSpending = Math.max(
0,
parseNumber(lifeTimeSpending) - creditCardMediaSpending,
);
const nettAmountBase = invoicesData.reduce( const nettAmountBase = invoicesData.reduce(
(sum, invoice) => sum + getBillableNettAmount(invoice), (sum, invoice) => sum + getBillableNettAmount(invoice),
0, 0,
); );
const shouldZeroSpendingAndRemaining =
const nettAmount = nettAmountBase + adjustmentNet; getInvoicesMediaItemsAreAllCreditCard(invoicesData) ||
const remainingAmount = getInvoicesMediaItemsAreAllCreditCard( normalizeCurrencyValue(nettAmountBase) === 0;
invoicesData, const billableInvoiceSpending = shouldZeroSpendingAndRemaining
)
? 0 ? 0
: Math.max(0, nettAmount - billableInvoiceSpending); : Math.max(
0,
parseNumber(lifeTimeSpending) - creditCardMediaSpending,
);
const adjustedNettAmount = nettAmountBase + adjustmentNet;
const remainingAmount = shouldZeroSpendingAndRemaining
? 0
: normalizeCurrencyValue(
adjustedNettAmount - billableInvoiceSpending,
);
return { return {
managementFee: invoicesData.reduce( managementFee: invoicesData.reduce(
@ -853,7 +837,7 @@ export default function TicketDetails({
invoiceSpending, invoiceSpending,
billableInvoiceSpending, billableInvoiceSpending,
adjustmentNet, adjustmentNet,
nettAmount, nettAmount: nettAmountBase,
remainingAmount, remainingAmount,
}; };
}, [clientInvoices, clientAdjustments, lifeTimeSpending]); }, [clientInvoices, clientAdjustments, lifeTimeSpending]);
@ -895,7 +879,7 @@ export default function TicketDetails({
color: 'green', color: 'green',
}, },
{ {
label: 'Spending Media Fee (Google Live)', label: 'Spending Media Fee (Live)',
value: invoiceTotals.billableInvoiceSpending, value: invoiceTotals.billableInvoiceSpending,
icon: IconCurrencyDollar, icon: IconCurrencyDollar,
color: 'indigo', color: 'indigo',
@ -905,6 +889,8 @@ export default function TicketDetails({
value: invoiceTotals.remainingAmount, value: invoiceTotals.remainingAmount,
icon: IconChartBar, icon: IconChartBar,
color: 'teal', color: 'teal',
valueColor:
invoiceTotals.remainingAmount < 0 ? 'red' : 'green',
}, },
{ {
label: 'Adjustments', label: 'Adjustments',
@ -1418,6 +1404,8 @@ export default function TicketDetails({
{ {
method: 'patch', method: 'patch',
data: { status }, data: { status },
onStart: () => setPageActionLoading(true),
onFinish: () => setPageActionLoading(false),
}, },
); );
}, },
@ -1438,6 +1426,8 @@ export default function TicketDetails({
}), }),
{ {
method: 'delete', method: 'delete',
onStart: () => setPageActionLoading(true),
onFinish: () => setPageActionLoading(false),
}, },
); );
}, },
@ -1484,7 +1474,7 @@ export default function TicketDetails({
<AppLayout> <AppLayout>
<Portal> <Portal>
<LoadingOverlay <LoadingOverlay
visible={loading} visible={loading || pageActionLoading}
zIndex={2000} zIndex={2000}
overlayOpacity={0.75} overlayOpacity={0.75}
overlayColor="#000" overlayColor="#000"
@ -1742,7 +1732,11 @@ export default function TicketDetails({
</ThemeIcon> </ThemeIcon>
</Group> </Group>
<Text size="lg" fw={600}> <Text
size="lg"
fw={600}
color={item.valueColor}
>
RM{' '} RM{' '}
{item.value.toLocaleString( {item.value.toLocaleString(
'en-MY', 'en-MY',
@ -1783,65 +1777,13 @@ export default function TicketDetails({
// enableRowActions // enableRowActions
/> />
<Modal <AdjustmentModal
opened={adjustmentModalOpened} opened={adjustmentModalOpened}
onClose={closeAdjustmentModal} onClose={closeAdjustmentModal}
title="Add adjustment" adjustment={selectedAdjustment}
centered clientId={localClientId}
> onProcessingChange={setPageActionLoading}
<Stack spacing="sm">
<Select
label="Entry type"
data={[
{ value: 'debit', label: 'Debit' },
{ value: 'credit', label: 'Credit' },
]}
value={adjustmentEntryType}
onChange={(value) =>
setAdjustmentEntryType(
(value as 'debit' | 'credit') ??
'debit',
)
}
required
/> />
<NumberInput
label="Amount"
precision={2}
step={0.01}
value={adjustmentAmount}
onChange={(value) =>
setAdjustmentAmount(value ?? '')
}
required
min={0}
/>
<Textarea
label="Remark"
value={adjustmentRemark}
onChange={(event) =>
setAdjustmentRemark(
event.currentTarget.value,
)
}
minRows={2}
/>
<Group position="right" spacing="xs" mt="sm">
<Button
variant="default"
onClick={closeAdjustmentModal}
>
Cancel
</Button>
<Button onClick={submitAdjustment}>
Save
</Button>
</Group>
</Stack>
</Modal>
</Paper> </Paper>
</Tabs.Panel> </Tabs.Panel>
@ -2018,6 +1960,156 @@ function AccountStatCard({ label, value, icon }: AccountStatCardProps) {
); );
} }
const adjustmentDefaults: AdjustmentFormValues = {
entry_type: 'debit',
amount: '',
remark: '',
};
const AdjustmentModal = memo(function AdjustmentModal({
opened,
onClose,
adjustment,
clientId,
onProcessingChange,
}: {
opened: boolean;
onClose: () => void;
adjustment: AdjustmentRow | null;
clientId: number;
onProcessingChange: (processing: boolean) => void;
}) {
const [values, setValues] =
useState<AdjustmentFormValues>(adjustmentDefaults);
const [processing, setProcessing] = useState(false);
useEffect(() => {
if (!opened) {
return;
}
setValues(
adjustment
? {
entry_type: adjustment.entry_type,
amount: adjustment.amount,
remark: adjustment.remark ?? '',
}
: adjustmentDefaults,
);
}, [adjustment, opened]);
const submit = () => {
onProcessingChange(true);
const payload = {
entry_type: values.entry_type,
amount: typeof values.amount === 'number' ? values.amount : 0,
remark: values.remark.trim() ? values.remark.trim() : null,
};
const options = {
preserveScroll: true,
preserveState: false,
onStart: () => setProcessing(true),
onFinish: () => {
setProcessing(false);
onProcessingChange(false);
},
onSuccess: () => {
onClose();
},
};
if (adjustment) {
router.patch(
route('clients.adjustments.update', {
adjustment: adjustment.id,
}),
payload,
options,
);
return;
}
router.post(
route('clients.adjustments.store', { client: clientId }),
payload,
options,
);
};
return (
<Modal
opened={opened}
onClose={onClose}
title={adjustment ? 'Edit adjustment' : 'Add adjustment'}
centered
>
<Stack spacing="sm">
<Select
label="Entry type"
data={[
{ value: 'debit', label: 'Debit' },
{ value: 'credit', label: 'Credit' },
]}
value={values.entry_type}
onChange={(value) =>
setValues((current) => ({
...current,
entry_type:
(value as 'debit' | 'credit') ?? 'debit',
}))
}
required
/>
<NumberInput
label="Amount"
precision={2}
step={0.01}
value={values.amount}
onChange={(value) =>
setValues((current) => ({
...current,
amount: value ?? '',
}))
}
required
min={0}
/>
<Textarea
label="Remark"
value={values.remark}
onChange={(event) => {
const remark = event.currentTarget.value;
setValues((current) => ({
...current,
remark,
}));
}}
minRows={2}
/>
<Group position="right" spacing="xs" mt="sm">
<Button
variant="default"
onClick={onClose}
disabled={processing}
>
Cancel
</Button>
<Button onClick={submit} loading={processing}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
});
function InvoicePaymentDetails({ invoice }: { invoice: InvoiceRow }) { function InvoicePaymentDetails({ invoice }: { invoice: InvoiceRow }) {
const payments = getInvoicePayments(invoice); const payments = getInvoicePayments(invoice);
@ -2267,7 +2359,8 @@ function PaymentItemHeader({
width, width,
padding: '8px 10px', padding: '8px 10px',
textAlign: 'left', textAlign: 'left',
borderBottom: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3] borderBottom: `1px solid ${
isDark ? theme.colors.dark[4] : theme.colors.gray[3]
}`, }`,
backgroundColor: isDark backgroundColor: isDark
? theme.colors.dark[7] ? theme.colors.dark[7]
@ -2297,7 +2390,8 @@ function PaymentItemCell({
style={{ style={{
padding: '10px', padding: '10px',
textAlign: align, textAlign: align,
borderBottom: `1px solid ${isDark ? theme.colors.dark[5] : theme.colors.gray[1] borderBottom: `1px solid ${
isDark ? theme.colors.dark[5] : theme.colors.gray[1]
}`, }`,
verticalAlign: 'top', verticalAlign: 'top',
}} }}

View File

@ -1,3 +1,4 @@
import InvoicePdfButton from '@/components/invoice-pdf-button';
import InvoiceForm, { import InvoiceForm, {
BillingItemTypeOption, BillingItemTypeOption,
InvoiceFormValues, InvoiceFormValues,
@ -9,7 +10,7 @@ import AppLayout from '@/layouts/app-layout';
import { ClientInvoice } from '@/types'; import { ClientInvoice } from '@/types';
import { Link, router, useForm } from '@inertiajs/react'; import { Link, router, useForm } from '@inertiajs/react';
import { Badge, Button, Container, Group, Title } from '@mantine/core'; import { Badge, Button, Container, Group, Title } from '@mantine/core';
import { IconArrowLeft, IconFileDollar } from '@tabler/icons-react'; import { IconArrowLeft } from '@tabler/icons-react';
import React from 'react'; import React from 'react';
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & { type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
@ -260,19 +261,12 @@ export default function Page({
Approve invoice Approve invoice
</Button> </Button>
)} */} )} */}
<Button <InvoicePdfButton
component="a" invoiceId={invoice.id}
href={route('client-invoices.getPdfInvoice', { invoiceNo={invoice.invoice_no}
id: invoice.id, display="button"
})} label="View invoice"
target="_blank" />
rel="noopener noreferrer"
leftIcon={<IconFileDollar size={16} />}
variant="light"
color="green"
>
View invoice
</Button>
<Button <Button
component={Link} component={Link}
href={ href={

View File

@ -1,15 +1,14 @@
<?php <?php
use Illuminate\Support\Facades\Route; use App\Http\Controllers\ActivityController;
use App\Http\Controllers\ClientInvoiceAdjustmentController;
use App\Http\Controllers\ClientInvoiceController;
use App\Http\Controllers\DashboardController; use App\Http\Controllers\DashboardController;
use App\Http\Controllers\GoogleAdsController; use App\Http\Controllers\GoogleAdsController;
use App\Http\Controllers\GoogleController; use App\Http\Controllers\GoogleController;
use App\Http\Controllers\ActivityController;
use App\Http\Controllers\ClientInvoiceController;
use App\Http\Controllers\ClientInvoiceAdjustmentController;
use App\Http\Controllers\Management\RoleController; use App\Http\Controllers\Management\RoleController;
use App\Http\Controllers\Management\UserController; use App\Http\Controllers\Management\UserController;
use App\Services\ClickHouseService; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return redirect()->route('login'); return redirect()->route('login');
@ -77,6 +76,8 @@
Route::put('{invoice}', 'update')->middleware('permission:client-invoices.update')->name('update'); Route::put('{invoice}', 'update')->middleware('permission:client-invoices.update')->name('update');
Route::patch('{invoice}/approve', 'approve')->middleware('permission:client-invoices.approve')->name('approve'); Route::patch('{invoice}/approve', 'approve')->middleware('permission:client-invoices.approve')->name('approve');
Route::delete('{invoice}', 'destroy')->middleware('permission:client-invoices.delete')->name('destroy'); Route::delete('{invoice}', 'destroy')->middleware('permission:client-invoices.delete')->name('destroy');
Route::get('/pdf/invoice/{invoice}/status', 'pdfInvoiceStatus')->middleware('permission:client-invoices.view-pdf')->name('pdf.status');
Route::post('/pdf/invoice/{invoice}', 'uploadPdfInvoice')->middleware('permission:client-invoices.view-pdf')->name('pdf.upload');
Route::get('/pdf/invoice/{id}', 'getPdfInvoice')->middleware('permission:client-invoices.view-pdf')->name('getPdfInvoice'); Route::get('/pdf/invoice/{id}', 'getPdfInvoice')->middleware('permission:client-invoices.view-pdf')->name('getPdfInvoice');
}); });
@ -85,6 +86,7 @@
->controller(ClientInvoiceAdjustmentController::class) ->controller(ClientInvoiceAdjustmentController::class)
->group(function () { ->group(function () {
Route::post('{client}/adjustments', 'store')->middleware('permission:clients.adjustments.create')->name('adjustments.store'); Route::post('{client}/adjustments', 'store')->middleware('permission:clients.adjustments.create')->name('adjustments.store');
Route::patch('adjustments/{adjustment}', 'update')->middleware('permission:clients.adjustments.create')->name('adjustments.update');
Route::delete('adjustments/{adjustment}', 'destroy')->middleware('permission:clients.adjustments.delete')->name('adjustments.destroy'); Route::delete('adjustments/{adjustment}', 'destroy')->middleware('permission:clients.adjustments.delete')->name('adjustments.destroy');
}); });

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Support\Facades\Route;
uses(Tests\TestCase::class);
beforeEach(function () {
Route::middleware('web')->post('/_test/page-expired', fn () => abort(419));
});
test('page expired web responses redirect back with a warning', function () {
$response = $this->from('/dashboard')->post('/_test/page-expired');
$response
->assertRedirect('/dashboard')
->assertSessionHas('message-warning', 'Your session expired. Please try again.');
});
test('page expired json responses keep the 419 status', function () {
$response = $this->postJson('/_test/page-expired');
$response->assertStatus(419);
});