feat: requested changes from HJ
This commit is contained in:
parent
027f7845b3
commit
7b5247ab50
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,30 +404,37 @@ 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);
|
||||
|
||||
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"',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'audience' => 'SEM',
|
||||
'invoice_numbers' => [$invoice->invoice_no],
|
||||
];
|
||||
$invoiceResponse = Http::acceptJson()
|
||||
->withHeaders([
|
||||
'X-Secret' => config('app.billing_key'),
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->get(config('app.billing_url').'/customer/invoices/', $payload);
|
||||
$pdfUrl = $this->externalInvoicePdfUrl($invoice);
|
||||
|
||||
abort_if($pdfUrl === null, 404, 'Invoice PDF not found.');
|
||||
|
||||
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()) {
|
||||
])->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();
|
||||
@ -434,15 +442,103 @@ function () use ($response) {
|
||||
200,
|
||||
[
|
||||
'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
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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<ColorScheme>(() =>
|
||||
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 (
|
||||
|
||||
198
resources/js/components/invoice-pdf-button.tsx
Normal file
198
resources/js/components/invoice-pdf-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={`/client-invoices/pdf/invoice/${row.original.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Tooltip label="View Invoice" withArrow withinPortal>
|
||||
<IconFileDollar color="green" />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
<InvoicePdfButton
|
||||
invoiceId={row.original.id}
|
||||
invoiceNo={row.original.invoice_no}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
|
||||
@ -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[];
|
||||
@ -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<CampaignDetail[]>([]);
|
||||
const [metrics, setMetrics] = useState<CampaignSummary>(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<number | ''>('');
|
||||
const [adjustmentRemark, setAdjustmentRemark] = useState('');
|
||||
const [selectedAdjustment, setSelectedAdjustment] =
|
||||
useState<AdjustmentRow | null>(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(
|
||||
@ -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 (
|
||||
<Group spacing="xs">
|
||||
{item.invoice_no !== null && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={route('client-invoices.getPdfInvoice', {
|
||||
id: item.id,
|
||||
})}
|
||||
target="_blank"
|
||||
>
|
||||
<Tooltip label={'View Invoice'} withArrow withinPortal>
|
||||
<IconFileDollar color="green" />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
<InvoicePdfButton
|
||||
invoiceId={item.id}
|
||||
invoiceNo={item.invoice_no}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
@ -772,6 +745,13 @@ export default function TicketDetails({
|
||||
row: MRT_Row<AdjustmentRow>;
|
||||
}) => (
|
||||
<Group spacing="xs">
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => openAdjustmentModal(row.original)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
@ -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',
|
||||
@ -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({
|
||||
<AppLayout>
|
||||
<Portal>
|
||||
<LoadingOverlay
|
||||
visible={loading}
|
||||
visible={loading || pageActionLoading}
|
||||
zIndex={2000}
|
||||
overlayOpacity={0.75}
|
||||
overlayColor="#000"
|
||||
@ -1742,7 +1732,11 @@ export default function TicketDetails({
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
|
||||
<Text size="lg" fw={600}>
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
color={item.valueColor}
|
||||
>
|
||||
RM{' '}
|
||||
{item.value.toLocaleString(
|
||||
'en-MY',
|
||||
@ -1783,65 +1777,13 @@ export default function TicketDetails({
|
||||
// enableRowActions
|
||||
/>
|
||||
|
||||
<Modal
|
||||
<AdjustmentModal
|
||||
opened={adjustmentModalOpened}
|
||||
onClose={closeAdjustmentModal}
|
||||
title="Add adjustment"
|
||||
centered
|
||||
>
|
||||
<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
|
||||
adjustment={selectedAdjustment}
|
||||
clientId={localClientId}
|
||||
onProcessingChange={setPageActionLoading}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</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 }) {
|
||||
const payments = getInvoicePayments(invoice);
|
||||
|
||||
@ -2267,7 +2359,8 @@ function PaymentItemHeader({
|
||||
width,
|
||||
padding: '8px 10px',
|
||||
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
|
||||
? theme.colors.dark[7]
|
||||
@ -2297,7 +2390,8 @@ function PaymentItemCell({
|
||||
style={{
|
||||
padding: '10px',
|
||||
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',
|
||||
}}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import InvoicePdfButton from '@/components/invoice-pdf-button';
|
||||
import InvoiceForm, {
|
||||
BillingItemTypeOption,
|
||||
InvoiceFormValues,
|
||||
@ -9,7 +10,7 @@ import AppLayout from '@/layouts/app-layout';
|
||||
import { ClientInvoice } from '@/types';
|
||||
import { Link, router, useForm } from '@inertiajs/react';
|
||||
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';
|
||||
|
||||
type InvoiceOptionSource = Pick<ClientInvoice, 'id' | 'invoice_no'> & {
|
||||
@ -260,19 +261,12 @@ export default function Page({
|
||||
Approve invoice
|
||||
</Button>
|
||||
)} */}
|
||||
<Button
|
||||
component="a"
|
||||
href={route('client-invoices.getPdfInvoice', {
|
||||
id: invoice.id,
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftIcon={<IconFileDollar size={16} />}
|
||||
variant="light"
|
||||
color="green"
|
||||
>
|
||||
View invoice
|
||||
</Button>
|
||||
<InvoicePdfButton
|
||||
invoiceId={invoice.id}
|
||||
invoiceNo={invoice.invoice_no}
|
||||
display="button"
|
||||
label="View invoice"
|
||||
/>
|
||||
<Button
|
||||
component={Link}
|
||||
href={
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
<?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\GoogleAdsController;
|
||||
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\UserController;
|
||||
use App\Services\ClickHouseService;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return redirect()->route('login');
|
||||
@ -77,6 +76,8 @@
|
||||
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/{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');
|
||||
});
|
||||
|
||||
@ -85,6 +86,7 @@
|
||||
->controller(ClientInvoiceAdjustmentController::class)
|
||||
->group(function () {
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
23
tests/Unit/PageExpiredResponseTest.php
Normal file
23
tests/Unit/PageExpiredResponseTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user