Compare commits
144 Commits
1e57fc1046
...
beads-sync
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fa1546f7 | ||
|
|
7c6eee187c | ||
|
|
fbd46b8e5c | ||
|
|
886b336a08 | ||
|
|
02237735ec | ||
|
|
5e420a0dd8 | ||
|
|
2a55ae934f | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 | ||
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f | ||
|
|
cff014ede5 | ||
|
|
8c5d3b93d5 | ||
|
|
22cb7ed7ce | ||
|
|
1ec4987b38 | ||
|
|
6542ac66f1 | ||
|
|
9bf4e8894f | ||
|
|
704683421f | ||
|
|
9e9e04b97e | ||
|
|
59c463dbd3 | ||
|
|
8af2db2976 | ||
|
|
a22bff1879 | ||
|
|
5009697f7b | ||
|
|
a8b9c3623a | ||
|
|
d5d53b563c | ||
|
|
c4fa0fc06e | ||
|
|
ee3e9737c4 | ||
|
|
322cafa3c2 | ||
|
|
232302eb6f | ||
|
|
ef1773d966 | ||
|
|
e3deec9741 | ||
|
|
10fbee4e6e | ||
|
|
4fe589f0e2 | ||
|
|
a3538f6470 | ||
|
|
e82a10cb8b | ||
|
|
cc89cc667a | ||
|
|
a796973861 | ||
|
|
eba212a056 | ||
|
|
54b3fa0d87 | ||
|
|
51e8beb46c | ||
|
|
33af04db1b | ||
|
|
29c3c42134 | ||
|
|
f89f6d6223 | ||
|
|
34eb2b94b3 | ||
|
|
88012c35bd | ||
|
|
3f3061a899 | ||
|
|
53eb560aa5 | ||
|
|
11dc0d77b4 | ||
|
|
35ef8f1586 | ||
|
|
15be3b847c | ||
|
|
7bbce79394 | ||
|
|
99186e8e2f | ||
|
|
148c075d58 | ||
|
|
e3b7271f69 | ||
|
|
7802bed394 | ||
|
|
2abd1d113f | ||
|
|
4718998e07 | ||
|
|
c07687102e | ||
|
|
8805c8264c | ||
|
|
15e19d4e8b | ||
|
|
48b1cfde09 | ||
|
|
540bb97f31 | ||
|
|
cbb010acca | ||
|
|
1afd49bd24 | ||
|
|
fae5ec26fb | ||
|
|
103c8d4dfd | ||
|
|
69fc869990 | ||
|
|
76c04f6873 | ||
|
|
6f5aa5e09b | ||
|
|
8764915fcd | ||
|
|
bd6a8b9c7c | ||
|
|
eb6c8857d1 | ||
|
|
a35808ac15 | ||
|
|
ef05822b70 | ||
|
|
4f1fbcc98b | ||
|
|
43b626cbfc | ||
|
|
3d0ff40382 | ||
|
|
f41578905f | ||
|
|
08fe64b965 | ||
|
|
7ea34b3b20 | ||
|
|
030a00ba46 | ||
|
|
41ed682fbe | ||
|
|
75d862748b | ||
|
|
66bf9e4a8c | ||
|
|
dfdbf09bf8 | ||
|
|
3c0e7afeb2 | ||
|
|
bb67d68eba | ||
|
|
0430f0b1cc | ||
|
|
8b445ae998 | ||
|
|
77b7af13d4 | ||
|
|
3e9f09571b | ||
|
|
eed7699549 | ||
|
|
5fd546c428 | ||
|
|
fc3e6715db | ||
|
|
9057a4cd15 | ||
|
|
bc99929040 | ||
|
|
8f13465415 | ||
|
|
412ecbe691 | ||
|
|
8b4950c79d | ||
|
|
2fc8232d57 | ||
|
|
6ca3c03179 | ||
|
|
cc25c2b506 | ||
|
|
c180b37760 | ||
|
|
25d464215e | ||
|
|
da06db2d3b | ||
|
|
117250879b | ||
|
|
4fbd0815a4 | ||
|
|
fdd707de67 | ||
|
|
73ce8608ec | ||
|
|
22f600bf44 | ||
|
|
405a4b7340 |
14
.beads/.gitignore
vendored
14
.beads/.gitignore
vendored
@@ -10,6 +10,7 @@ daemon.lock
|
|||||||
daemon.log
|
daemon.log
|
||||||
daemon.pid
|
daemon.pid
|
||||||
bd.sock
|
bd.sock
|
||||||
|
sync-state.json
|
||||||
|
|
||||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
.local_version
|
.local_version
|
||||||
@@ -18,6 +19,10 @@ bd.sock
|
|||||||
db.sqlite
|
db.sqlite
|
||||||
bd.db
|
bd.db
|
||||||
|
|
||||||
|
# Worktree redirect file (contains relative path to main repo's .beads/)
|
||||||
|
# Must not be committed as paths would be wrong in other clones
|
||||||
|
redirect
|
||||||
|
|
||||||
# Merge artifacts (temporary files from 3-way merge)
|
# Merge artifacts (temporary files from 3-way merge)
|
||||||
beads.base.jsonl
|
beads.base.jsonl
|
||||||
beads.base.meta.json
|
beads.base.meta.json
|
||||||
@@ -26,7 +31,8 @@ beads.left.meta.json
|
|||||||
beads.right.jsonl
|
beads.right.jsonl
|
||||||
beads.right.meta.json
|
beads.right.meta.json
|
||||||
|
|
||||||
# Keep JSONL exports and config (source of truth for git)
|
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
|
||||||
!issues.jsonl
|
# They would override fork protection in .git/info/exclude, allowing
|
||||||
!metadata.json
|
# contributors to accidentally commit upstream issue databases.
|
||||||
!config.json
|
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
|
||||||
|
# are tracked by git by default since no pattern above ignores them.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{"id":"--stealth-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00","close_reason":"Duplicate of fotospiel-app-ihd after beads re-init"}
|
||||||
|
{"id":"fotospiel-app-097","title":"Tenant announcements / release notes","description":"Broadcast announcements to tenants/admins with targeting and scheduling.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:21.68206312+01:00","updated_at":"2026-01-01T14:20:21.68206312+01:00"}
|
||||||
|
{"id":"fotospiel-app-arp","title":"Guest policy settings (toggles, rate limits, retention defaults)","description":"Global guest feature toggles, rate limits, and retention defaults. Settings page + persistence.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:18:52.931017783+01:00","updated_at":"2026-01-01T14:18:52.931017783+01:00"}
|
||||||
|
{"id":"fotospiel-app-bit","title":"Superadmin control surface roadmap","description":"Roadmap to implement practical superadmin control over tenant admin + guest experience. Tracks lifecycle, moderation, policies, ops health, compliance, audit, announcements, integrations.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-01-01T14:21:01.852988935+01:00","updated_at":"2026-01-01T14:21:01.852988935+01:00","dependencies":[{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-ihd","type":"blocks","created_at":"2026-01-01T14:21:14.445938122+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-wde","type":"blocks","created_at":"2026-01-01T14:21:16.788922347+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-hbt","type":"blocks","created_at":"2026-01-01T14:21:18.300493488+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-arp","type":"blocks","created_at":"2026-01-01T14:21:20.731646568+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-tym","type":"blocks","created_at":"2026-01-01T14:21:23.219093242+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-sbs","type":"blocks","created_at":"2026-01-01T14:21:24.67996941+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-iyc","type":"blocks","created_at":"2026-01-01T14:21:27.027185624+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-097","type":"blocks","created_at":"2026-01-01T14:21:29.668197239+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-bit","depends_on_id":"fotospiel-app-lqp","type":"blocks","created_at":"2026-01-01T14:21:31.238481004+01:00","created_by":"soeren"}]}
|
||||||
|
{"id":"fotospiel-app-hbt","title":"Moderation queue for guest content","description":"Queue for flagged guest content (photos, feedback). Bulk actions to hide/delete/resolve with audit.","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:37.777772819+01:00","updated_at":"2026-01-01T14:18:37.777772819+01:00"}
|
||||||
|
{"id":"fotospiel-app-ihd","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T14:18:10.789147344+01:00","updated_at":"2026-01-01T14:18:10.789147344+01:00"}
|
||||||
|
{"id":"fotospiel-app-iyc","title":"Superadmin audit log for admin actions","description":"Audit trail for superadmin actions without PII payloads.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:19.043695952+01:00","updated_at":"2026-01-01T14:20:19.043695952+01:00"}
|
||||||
|
{"id":"fotospiel-app-lqp","title":"Integrations health (Paddle/RevenueCat/webhooks)","description":"Health/status dashboard for payment and webhook integrations.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:25.197673148+01:00","updated_at":"2026-01-01T14:20:25.197673148+01:00"}
|
||||||
|
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-01T14:20:16.530289009+01:00"}
|
||||||
|
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-01T14:20:04.991351193+01:00"}
|
||||||
|
{"id":"fotospiel-app-wde","title":"Tenant lifecycle controls (status, limits, suspend/grace)","description":"Superadmin controls for tenant status, grace periods, and hard limits (uploads/storage/events). Includes UI, policy checks, and audit events.","status":"open","priority":1,"issue_type":"feature","created_at":"2026-01-01T14:18:23.062036821+01:00","updated_at":"2026-01-01T14:18:23.062036821+01:00"}
|
||||||
|
|||||||
172
.beads/issues.jsonl
Normal file
172
.beads/issues.jsonl
Normal file
File diff suppressed because one or more lines are too long
1
.beads/last-touched
Normal file
1
.beads/last-touched
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fotospiel-app-29r
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"database": "beads.db",
|
"database": "beads.db",
|
||||||
"jsonl_export": "interactions.jsonl"
|
"jsonl_export": "issues.jsonl"
|
||||||
}
|
}
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
|||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
/clients/photobooth-uploader/**/bin
|
||||||
|
/clients/photobooth-uploader/**/obj
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
@@ -23,11 +25,9 @@ Homestead.yaml
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/auth.json
|
/auth.json
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
|
||||||
tools/git-askpass.ps1
|
|
||||||
podman-compose.dev.yml
|
|
||||||
test-results
|
test-results
|
||||||
|
GEMINI.md
|
||||||
|
.beads/.sync.lock
|
||||||
|
.beads/daemon-error
|
||||||
|
.beads/sync_base.jsonl
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,8 +33,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
|
|
||||||
## Repo Structure (high-level)
|
## Repo Structure (high-level)
|
||||||
- docs/archive/prp/ — split PRP (authoritative). Start at docs/archive/prp/README.md.
|
- docs/archive/prp/ — split PRP (authoritative). Start at docs/archive/prp/README.md.
|
||||||
- docs/process/changes/ — session change logs.
|
- .beads/ — bd issue tracker data (source of truth for backlog and progress).
|
||||||
- docs/process/todo/ — prioritized backlog items (replaces single TODO.md file).
|
|
||||||
- resources/js/guest/ — Guest PWA source (React 19, offline-first, installable).
|
- resources/js/guest/ — Guest PWA source (React 19, offline-first, installable).
|
||||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||||
- resources/js/pages/ — Inertia pages (React).
|
- resources/js/pages/ — Inertia pages (React).
|
||||||
@@ -48,7 +47,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
4) Update docs when public surfaces change (PRP, docs/*).
|
4) Update docs when public surfaces change (PRP, docs/*).
|
||||||
5) Propose follow-ups as Issues if out of scope.
|
5) Propose follow-ups as Issues if out of scope.
|
||||||
- Issue hygiene (Ops Agent):
|
- Issue hygiene (Ops Agent):
|
||||||
- Import docs/process/todo/ tasks as Issues with label TODO; group by Milestone (e.g., Now, Security & Compliance).
|
- Track backlog and follow-ups in bd; avoid duplicates by checking existing titles before creating new issues.
|
||||||
- Avoid duplicates by checking existing titles.
|
- Avoid duplicates by checking existing titles.
|
||||||
- Releases (Ops Agent):
|
- Releases (Ops Agent):
|
||||||
- Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated.
|
- Tag with semantic version; generate changelog from commits/PRs; ensure legal pages and migration notes are updated.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
|||||||
use App\Console\Concerns\InteractsWithCacheLocks;
|
use App\Console\Concerns\InteractsWithCacheLocks;
|
||||||
use App\Jobs\ArchiveEventMediaAssets;
|
use App\Jobs\ArchiveEventMediaAssets;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Services\Compliance\RetentionOverrideService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Contracts\Cache\Lock;
|
use Illuminate\Contracts\Cache\Lock;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command
|
|||||||
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
|
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
|
||||||
$eventId = $this->option('event');
|
$eventId = $this->option('event');
|
||||||
$dispatched = 0;
|
$dispatched = 0;
|
||||||
|
$overrides = app(RetentionOverrideService::class);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$query = Event::query()
|
$query = Event::query()
|
||||||
@@ -57,12 +59,16 @@ class DispatchStorageArchiveCommand extends Command
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) {
|
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl, $overrides) {
|
||||||
foreach ($events as $event) {
|
foreach ($events as $event) {
|
||||||
if ($dispatched >= $maxDispatch) {
|
if ($dispatched >= $maxDispatch) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($overrides->eventOnHold($event)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
|
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
|
||||||
if ($eventLock === false) {
|
if ($eventLock === false) {
|
||||||
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [
|
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [
|
||||||
|
|||||||
40
app/Console/Commands/DispatchTenantAnnouncements.php
Normal file
40
app/Console/Commands/DispatchTenantAnnouncements.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\TenantAnnouncements\TenantAnnouncementService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class DispatchTenantAnnouncements extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'tenant-announcements:dispatch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Dispatch scheduled tenant announcements and queue email notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(TenantAnnouncementService $service): int
|
||||||
|
{
|
||||||
|
$result = $service->process();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Announcements: %d activated, %d archived, %d emails queued.',
|
||||||
|
$result['activated'],
|
||||||
|
$result['archived'],
|
||||||
|
$result['queued'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Paddle\PaddleClient;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PaddleRegisterWebhooks extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'paddle:webhooks:register
|
||||||
|
{--url= : Destination URL for Paddle webhooks}
|
||||||
|
{--description= : Description for the webhook destination}
|
||||||
|
{--events=* : Override event types to subscribe}
|
||||||
|
{--traffic-source=all : platform|simulation|all}
|
||||||
|
{--include-sensitive : Include sensitive fields in webhook payloads}
|
||||||
|
{--show-secret : Output the endpoint secret key}
|
||||||
|
{--dry-run : Output payload without creating the destination}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Register Paddle webhook notification settings.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(PaddleClient $client): int
|
||||||
|
{
|
||||||
|
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
||||||
|
|
||||||
|
if ($destination === '') {
|
||||||
|
$this->error('Webhook destination URL is required. Use --url=...');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = collect((array) $this->option('events'))
|
||||||
|
->filter()
|
||||||
|
->map(fn ($event) => trim((string) $event))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($events === []) {
|
||||||
|
$events = config('paddle.webhook_events', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($events === [] || ! is_array($events)) {
|
||||||
|
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trafficSource = (string) $this->option('traffic-source');
|
||||||
|
$allowedSources = ['platform', 'simulation', 'all'];
|
||||||
|
|
||||||
|
if (! in_array($trafficSource, $allowedSources, true)) {
|
||||||
|
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'type' => 'url',
|
||||||
|
'destination' => $destination,
|
||||||
|
'description' => $this->resolveDescription(),
|
||||||
|
'subscribed_events' => $events,
|
||||||
|
'traffic_source' => $trafficSource,
|
||||||
|
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ((bool) $this->option('dry-run')) {
|
||||||
|
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $client->post('/notification-settings', $payload);
|
||||||
|
$data = Arr::get($response, 'data', $response);
|
||||||
|
$id = Arr::get($data, 'id');
|
||||||
|
$secret = Arr::get($data, 'endpoint_secret_key');
|
||||||
|
|
||||||
|
Log::channel('paddle-sync')->info('Paddle webhook registered', [
|
||||||
|
'notification_setting_id' => $id,
|
||||||
|
'destination' => $destination,
|
||||||
|
'traffic_source' => $trafficSource,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->info('Paddle webhook registered.');
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$this->line('ID: '.$id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($secret && $this->option('show-secret')) {
|
||||||
|
$this->line('Secret: '.$secret);
|
||||||
|
} elseif ($secret) {
|
||||||
|
$this->line('Secret returned (hidden). Use --show-secret to display.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defaultWebhookUrl(): string
|
||||||
|
{
|
||||||
|
$base = rtrim((string) config('app.url'), '/');
|
||||||
|
|
||||||
|
return $base !== '' ? $base.'/paddle/webhook' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveDescription(): string
|
||||||
|
{
|
||||||
|
$description = (string) $this->option('description');
|
||||||
|
|
||||||
|
if ($description !== '') {
|
||||||
|
return $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
$environment = (string) config('paddle.environment', 'production');
|
||||||
|
|
||||||
|
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class PaddleSyncPackages extends Command
|
|||||||
{--package=* : Limit sync to the given package IDs or slugs}
|
{--package=* : Limit sync to the given package IDs or slugs}
|
||||||
{--dry-run : Generate payload snapshots without calling Paddle}
|
{--dry-run : Generate payload snapshots without calling Paddle}
|
||||||
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
||||||
|
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs}
|
||||||
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
{--queue : Dispatch jobs onto the queue instead of running synchronously}';
|
||||||
|
|
||||||
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
||||||
@@ -32,6 +33,13 @@ class PaddleSyncPackages extends Command
|
|||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$pull = (bool) $this->option('pull');
|
$pull = (bool) $this->option('pull');
|
||||||
$queue = (bool) $this->option('queue');
|
$queue = (bool) $this->option('queue');
|
||||||
|
$allowUnmapped = (bool) $this->option('allow-unmapped');
|
||||||
|
|
||||||
|
if (! $pull && ! $allowUnmapped && ! $this->hasPackageFilter()) {
|
||||||
|
if (! $this->guardUnmappedPackages($packages)) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
|
$packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
|
||||||
if ($pull) {
|
if ($pull) {
|
||||||
@@ -82,6 +90,42 @@ class PaddleSyncPackages extends Command
|
|||||||
return $query->orderByDesc('id')->get();
|
return $query->orderByDesc('id')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function hasPackageFilter(): bool
|
||||||
|
{
|
||||||
|
return collect((array) $this->option('package'))->filter()->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function guardUnmappedPackages(Collection $packages): bool
|
||||||
|
{
|
||||||
|
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
|
||||||
|
|
||||||
|
if ($unmapped->isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
|
||||||
|
$this->table(
|
||||||
|
['ID', 'Slug', 'Missing'],
|
||||||
|
$unmapped->map(function (Package $package): array {
|
||||||
|
$missing = [];
|
||||||
|
if (blank($package->paddle_product_id)) {
|
||||||
|
$missing[] = 'product_id';
|
||||||
|
}
|
||||||
|
if (blank($package->paddle_price_id)) {
|
||||||
|
$missing[] = 'price_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$package->id,
|
||||||
|
$package->slug,
|
||||||
|
implode(', ', $missing),
|
||||||
|
];
|
||||||
|
})->values()->all()
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
|
protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
|
||||||
{
|
{
|
||||||
$context = [
|
$context = [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
|||||||
use App\Jobs\AnonymizeAccount;
|
use App\Jobs\AnonymizeAccount;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\InactiveTenantDeletionWarning;
|
use App\Notifications\InactiveTenantDeletionWarning;
|
||||||
|
use App\Services\Compliance\RetentionOverrideService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
@@ -27,7 +28,13 @@ class ProcessTenantRetention extends Command
|
|||||||
->withMax('purchases as last_purchase_activity', 'purchased_at')
|
->withMax('purchases as last_purchase_activity', 'purchased_at')
|
||||||
->withMax('photos as last_photo_activity', 'created_at')
|
->withMax('photos as last_photo_activity', 'created_at')
|
||||||
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
|
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
|
||||||
|
$overrides = app(RetentionOverrideService::class);
|
||||||
|
|
||||||
foreach ($tenants as $tenant) {
|
foreach ($tenants as $tenant) {
|
||||||
|
if ($overrides->tenantOnHold($tenant)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$lastActivity = $this->determineLastActivity($tenant);
|
$lastActivity = $this->determineLastActivity($tenant);
|
||||||
|
|
||||||
if (! $lastActivity) {
|
if (! $lastActivity) {
|
||||||
|
|||||||
@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
||||||
|
|
||||||
|
TenantPackage::updateOrCreate(
|
||||||
|
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||||
|
[
|
||||||
|
'price' => $packages['standard']->price,
|
||||||
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
|
'used_events' => 0,
|
||||||
|
'active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['starter'],
|
package: $packages['standard'],
|
||||||
eventType: $eventTypes['wedding'] ?? null,
|
eventType: $eventTypes['wedding'] ?? null,
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||||
|
|||||||
19
app/Enums/DataExportScope.php
Normal file
19
app/Enums/DataExportScope.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum DataExportScope: string
|
||||||
|
{
|
||||||
|
case USER = 'user';
|
||||||
|
case TENANT = 'tenant';
|
||||||
|
case EVENT = 'event';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::USER => __('User'),
|
||||||
|
self::TENANT => __('Tenant'),
|
||||||
|
self::EVENT => __('Event'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Enums/PhotoLiveStatus.php
Normal file
12
app/Enums/PhotoLiveStatus.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PhotoLiveStatus: string
|
||||||
|
{
|
||||||
|
case NONE = 'none';
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case APPROVED = 'approved';
|
||||||
|
case REJECTED = 'rejected';
|
||||||
|
case EXPIRED = 'expired';
|
||||||
|
}
|
||||||
17
app/Enums/RetentionOverrideScope.php
Normal file
17
app/Enums/RetentionOverrideScope.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum RetentionOverrideScope: string
|
||||||
|
{
|
||||||
|
case TENANT = 'tenant';
|
||||||
|
case EVENT = 'event';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::TENANT => __('Tenant'),
|
||||||
|
self::EVENT => __('Event'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Enums/TenantAnnouncementAudience.php
Normal file
19
app/Enums/TenantAnnouncementAudience.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TenantAnnouncementAudience: string
|
||||||
|
{
|
||||||
|
case ALL = 'all';
|
||||||
|
case TENANTS = 'tenants';
|
||||||
|
case SEGMENTS = 'segments';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ALL => __('Alle Tenants'),
|
||||||
|
self::TENANTS => __('Ausgewählte Tenants'),
|
||||||
|
self::SEGMENTS => __('Segmente'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Enums/TenantAnnouncementDeliveryStatus.php
Normal file
19
app/Enums/TenantAnnouncementDeliveryStatus.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TenantAnnouncementDeliveryStatus: string
|
||||||
|
{
|
||||||
|
case QUEUED = 'queued';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
case SKIPPED = 'skipped';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::QUEUED => __('Warteschlange'),
|
||||||
|
self::FAILED => __('Fehlgeschlagen'),
|
||||||
|
self::SKIPPED => __('Übersprungen'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Enums/TenantAnnouncementSegment.php
Normal file
17
app/Enums/TenantAnnouncementSegment.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TenantAnnouncementSegment: string
|
||||||
|
{
|
||||||
|
case ACTIVE_PACKAGE = 'active_package';
|
||||||
|
case ACTIVE_STATUS = 'active_status';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ACTIVE_PACKAGE => __('Aktives Paket'),
|
||||||
|
self::ACTIVE_STATUS => __('Aktiv (nicht gesperrt/gelöscht)'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Enums/TenantAnnouncementStatus.php
Normal file
21
app/Enums/TenantAnnouncementStatus.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TenantAnnouncementStatus: string
|
||||||
|
{
|
||||||
|
case DRAFT = 'draft';
|
||||||
|
case SCHEDULED = 'scheduled';
|
||||||
|
case ACTIVE = 'active';
|
||||||
|
case ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRAFT => __('Entwurf'),
|
||||||
|
self::SCHEDULED => __('Geplant'),
|
||||||
|
self::ACTIVE => __('Aktiv'),
|
||||||
|
self::ARCHIVED => __('Archiviert'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Filament\Blog\Resources\CategoryResource\Pages;
|
|||||||
use App\Filament\Blog\Traits\HasContentEditor;
|
use App\Filament\Blog\Traits\HasContentEditor;
|
||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
use App\Models\BlogCategory;
|
use App\Models\BlogCategory;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
@@ -24,6 +25,7 @@ use Filament\Tables\Columns\IconColumn;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -116,38 +118,25 @@ class CategoryResource extends Resource
|
|||||||
$data['description_de'] = $descArray['de'] ?? '';
|
$data['description_de'] = $descArray['de'] ?? '';
|
||||||
$data['description_en'] = $descArray['en'] ?? '';
|
$data['description_en'] = $descArray['en'] ?? '';
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Log::info('BeforeFill Description Extraction:', [
|
|
||||||
'descJson' => $descJson,
|
|
||||||
'descArray' => $descArray,
|
|
||||||
'description_de' => $data['description_de'],
|
|
||||||
'description_en' => $data['description_en'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function mutateFormDataBeforeCreate(array $data): array
|
public static function mutateFormDataBeforeCreate(array $data): array
|
||||||
{
|
{
|
||||||
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Input Data:', ['data' => $data]);
|
|
||||||
|
|
||||||
$nameData = [
|
$nameData = [
|
||||||
'de' => $data['name_de'] ?? '',
|
'de' => $data['name_de'] ?? '',
|
||||||
'en' => $data['name_en'] ?? '',
|
'en' => $data['name_en'] ?? '',
|
||||||
];
|
];
|
||||||
$data['name'] = json_encode($nameData);
|
$data['name'] = json_encode($nameData);
|
||||||
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Name JSON:', ['name' => $nameData]);
|
|
||||||
|
|
||||||
$descData = [
|
$descData = [
|
||||||
'de' => $data['description_de'] ?? '',
|
'de' => $data['description_de'] ?? '',
|
||||||
'en' => $data['description_en'] ?? '',
|
'en' => $data['description_en'] ?? '',
|
||||||
];
|
];
|
||||||
$data['description'] = json_encode($descData);
|
$data['description'] = json_encode($descData);
|
||||||
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Description JSON:', ['description' => $descData]);
|
|
||||||
|
|
||||||
unset($data['name_de'], $data['name_en'], $data['description_de'], $data['description_en']);
|
unset($data['name_de'], $data['name_en'], $data['description_de'], $data['description_en']);
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Final Data:', $data);
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +174,28 @@ class CategoryResource extends Resource
|
|||||||
//
|
//
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
|
->after(fn (array $data, BlogCategory $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Blog\Resources\CategoryResource\Pages;
|
namespace App\Filament\Blog\Resources\CategoryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Blog\Resources\CategoryResource;
|
use App\Filament\Blog\Resources\CategoryResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreateCategory extends CreateRecord
|
class CreateCategory extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = CategoryResource::class;
|
protected static string $resource = CategoryResource::class;
|
||||||
|
|
||||||
@@ -30,4 +30,4 @@ class CreateCategory extends CreateRecord
|
|||||||
|
|
||||||
$this->record = static::getResource()::getModel()::create($data);
|
$this->record = static::getResource()::getModel()::create($data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,23 @@
|
|||||||
namespace App\Filament\Blog\Resources\CategoryResource\Pages;
|
namespace App\Filament\Blog\Resources\CategoryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Blog\Resources\CategoryResource;
|
use App\Filament\Blog\Resources\CategoryResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditCategory extends EditRecord
|
class EditCategory extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = CategoryResource::class;
|
protected static string $resource = CategoryResource::class;
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +30,7 @@ class EditCategory extends EditRecord
|
|||||||
'description_de' => 'nullable|string',
|
'description_de' => 'nullable|string',
|
||||||
'name_en' => 'nullable|string|max:255',
|
'name_en' => 'nullable|string|max:255',
|
||||||
'description_en' => 'nullable|string',
|
'description_en' => 'nullable|string',
|
||||||
'slug' => 'required|string|max:255|unique:blog_categories,slug,' . $this->record->id,
|
'slug' => 'required|string|max:255|unique:blog_categories,slug,'.$this->record->id,
|
||||||
'is_visible' => 'boolean',
|
'is_visible' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -40,12 +46,13 @@ class EditCategory extends EditRecord
|
|||||||
public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void
|
public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void
|
||||||
{
|
{
|
||||||
$state = $this->form->getState();
|
$state = $this->form->getState();
|
||||||
\Illuminate\Support\Facades\Log::info('EditCategory Save - Full State:', $state);
|
|
||||||
|
|
||||||
$data = $state['data'] ?? $state;
|
$data = $state['data'] ?? $state;
|
||||||
|
|
||||||
$data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeSave($data);
|
$data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeSave($data);
|
||||||
|
|
||||||
$this->record->update($data);
|
$this->record->update($data);
|
||||||
|
|
||||||
|
parent::afterSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Filament\Blog\Traits\HasContentEditor;
|
|||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
use App\Models\BlogCategory;
|
use App\Models\BlogCategory;
|
||||||
use App\Models\BlogPost;
|
use App\Models\BlogPost;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -29,6 +30,7 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -243,11 +245,27 @@ class PostResource extends Resource
|
|||||||
->actions([
|
->actions([
|
||||||
DeleteAction::make()
|
DeleteAction::make()
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->label(''),
|
->label('')
|
||||||
|
->after(fn (BlogPost $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Blog\Resources\PostResource\Pages;
|
namespace App\Filament\Blog\Resources\PostResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Blog\Resources\PostResource;
|
use App\Filament\Blog\Resources\PostResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreatePost extends CreateRecord
|
class CreatePost extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PostResource::class;
|
protected static string $resource = PostResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace App\Filament\Blog\Resources\PostResource\Pages;
|
namespace App\Filament\Blog\Resources\PostResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Blog\Resources\PostResource;
|
use App\Filament\Blog\Resources\PostResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditPost extends EditRecord
|
class EditPost extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PostResource::class;
|
protected static string $resource = PostResource::class;
|
||||||
|
|
||||||
@@ -14,7 +15,12 @@ class EditPost extends EditRecord
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||||
|
use App\Filament\Widgets\JoinTokenOverviewWidget;
|
||||||
|
use App\Filament\Widgets\JoinTokenTopTokensWidget;
|
||||||
|
use App\Filament\Widgets\JoinTokenTrendWidget;
|
||||||
|
use App\Models\Event;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Pages\Dashboard;
|
||||||
|
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class JoinTokenAnalyticsDashboard extends Dashboard
|
||||||
|
{
|
||||||
|
use HasFiltersForm;
|
||||||
|
|
||||||
|
protected static ?string $cluster = DailyOpsCluster::class;
|
||||||
|
|
||||||
|
protected static string $routePath = 'join-token-analytics';
|
||||||
|
|
||||||
|
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-chart-bar';
|
||||||
|
|
||||||
|
protected static null|string|UnitEnum $navigationGroup = null;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 12;
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.security');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.join_token_analytics.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return __('admin.join_token_analytics.heading');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): ?string
|
||||||
|
{
|
||||||
|
return __('admin.join_token_analytics.subheading');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColumns(): int|array
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
JoinTokenOverviewWidget::class,
|
||||||
|
JoinTokenTrendWidget::class,
|
||||||
|
JoinTokenTopTokensWidget::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filtersForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make()
|
||||||
|
->schema([
|
||||||
|
Select::make('range')
|
||||||
|
->label(__('admin.join_token_analytics.filters.range'))
|
||||||
|
->options(trans('admin.join_token_analytics.filters.range_options'))
|
||||||
|
->default('24h')
|
||||||
|
->native(false),
|
||||||
|
Select::make('event_id')
|
||||||
|
->label(__('admin.join_token_analytics.filters.event'))
|
||||||
|
->placeholder(__('admin.join_token_analytics.filters.event_placeholder'))
|
||||||
|
->searchable()
|
||||||
|
->getSearchResultsUsing(fn (string $search): array => $this->searchEvents($search))
|
||||||
|
->getOptionLabelUsing(fn ($value): ?string => $this->resolveEventLabel($value))
|
||||||
|
->native(false),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function searchEvents(string $search): array
|
||||||
|
{
|
||||||
|
return Event::query()
|
||||||
|
->with('tenant')
|
||||||
|
->when($search !== '', function ($query) use ($search) {
|
||||||
|
$query->where('slug', 'like', "%{$search}%")
|
||||||
|
->orWhere('name->de', 'like', "%{$search}%")
|
||||||
|
->orWhere('name->en', 'like', "%{$search}%");
|
||||||
|
})
|
||||||
|
->orderByDesc('date')
|
||||||
|
->limit(25)
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn (Event $event) => [$event->id => $this->formatEventLabel($event)])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveEventLabel(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = Event::query()
|
||||||
|
->with('tenant')
|
||||||
|
->find((int) $value);
|
||||||
|
|
||||||
|
return $event ? $this->formatEventLabel($event) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatEventLabel(Event $event): string
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
|
||||||
|
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
|
||||||
|
$date = $event->date?->format('Y-m-d');
|
||||||
|
|
||||||
|
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreatePhoto extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PhotoResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
|
||||||
|
class EditPhoto extends AuditedEditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PhotoResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ViewAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPhotos extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PhotoResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewPhoto extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PhotoResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ListPhotos;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ViewPhoto;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoForm;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoInfolist;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\Photos\Tables\PhotosTable;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class PhotoResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Photo::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
|
||||||
|
|
||||||
|
protected static ?string $cluster = DailyOpsCluster::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'moderation-queue';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'id';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PhotoForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PhotoInfolist::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return PhotosTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with(['event.tenant', 'moderator']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.moderation.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.curation');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListPhotos::route('/'),
|
||||||
|
'view' => ViewPhoto::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PhotoForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Select::make('event_id')
|
||||||
|
->relationship('event', 'name')
|
||||||
|
->required(),
|
||||||
|
Select::make('emotion_id')
|
||||||
|
->relationship('emotion', 'name'),
|
||||||
|
Select::make('task_id')
|
||||||
|
->relationship('task', 'title'),
|
||||||
|
TextInput::make('guest_name')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('file_path')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('thumbnail_path')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('likes_count')
|
||||||
|
->required()
|
||||||
|
->numeric()
|
||||||
|
->default(0),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->required(),
|
||||||
|
Textarea::make('metadata')
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('tenant_id')
|
||||||
|
->numeric(),
|
||||||
|
Select::make('media_asset_id')
|
||||||
|
->relationship('mediaAsset', 'id'),
|
||||||
|
TextInput::make('security_scan_status')
|
||||||
|
->required()
|
||||||
|
->default('pending'),
|
||||||
|
Textarea::make('security_scan_message')
|
||||||
|
->columnSpanFull(),
|
||||||
|
DateTimePicker::make('security_scanned_at'),
|
||||||
|
Textarea::make('security_meta')
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('ingest_source')
|
||||||
|
->required()
|
||||||
|
->default('guest_pwa'),
|
||||||
|
TextInput::make('filename'),
|
||||||
|
TextInput::make('original_name'),
|
||||||
|
TextInput::make('mime_type'),
|
||||||
|
TextInput::make('size')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('width')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('height')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('status')
|
||||||
|
->required()
|
||||||
|
->default('pending'),
|
||||||
|
TextInput::make('uploader_id')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('ip_address'),
|
||||||
|
Textarea::make('user_agent')
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('created_by_device_id'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
|
||||||
|
|
||||||
|
use App\Models\Photo;
|
||||||
|
use Filament\Infolists\Components\ImageEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PhotoInfolist
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make(__('admin.moderation.sections.photo'))
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
ImageEntry::make('thumbnail_path')
|
||||||
|
->label(__('admin.moderation.fields.photo'))
|
||||||
|
->disk('public')
|
||||||
|
->visibility('public')
|
||||||
|
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path)
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('event.name')
|
||||||
|
->label(__('admin.moderation.fields.event'))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('event.tenant.name')
|
||||||
|
->label(__('admin.moderation.fields.tenant'))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('guest_name')
|
||||||
|
->label(__('admin.moderation.fields.uploader'))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->label(__('admin.moderation.fields.uploaded_at'))
|
||||||
|
->since()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('ingest_source')
|
||||||
|
->label(__('admin.moderation.fields.ingest_source'))
|
||||||
|
->formatStateUsing(fn (?string $state) => match ($state) {
|
||||||
|
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
|
||||||
|
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
|
||||||
|
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
|
||||||
|
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
|
||||||
|
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
|
||||||
|
default => '—',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Section::make(__('admin.moderation.sections.moderation'))
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->label(__('admin.moderation.fields.status'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state) => match ($state) {
|
||||||
|
'approved' => 'success',
|
||||||
|
'rejected' => 'danger',
|
||||||
|
'hidden' => 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (?string $state) => match ($state) {
|
||||||
|
'pending' => __('admin.moderation.status.pending'),
|
||||||
|
'approved' => __('admin.moderation.status.approved'),
|
||||||
|
'rejected' => __('admin.moderation.status.rejected'),
|
||||||
|
'hidden' => __('admin.moderation.status.hidden'),
|
||||||
|
default => '—',
|
||||||
|
}),
|
||||||
|
TextEntry::make('moderator.name')
|
||||||
|
->label(__('admin.moderation.fields.moderated_by'))
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('moderated_at')
|
||||||
|
->label(__('admin.moderation.fields.moderated_at'))
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('moderation_notes')
|
||||||
|
->label(__('admin.moderation.fields.moderation_notes'))
|
||||||
|
->placeholder('—')
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('security_scan_status')
|
||||||
|
->label(__('admin.moderation.fields.security_scan_status'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state) => match ($state) {
|
||||||
|
'clean', 'skipped', 'stripped' => 'success',
|
||||||
|
'infected' => 'danger',
|
||||||
|
'error' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (?string $state) => match ($state) {
|
||||||
|
'pending' => __('admin.moderation.security_scan.pending'),
|
||||||
|
'clean' => __('admin.moderation.security_scan.clean'),
|
||||||
|
'infected' => __('admin.moderation.security_scan.infected'),
|
||||||
|
'skipped' => __('admin.moderation.security_scan.skipped'),
|
||||||
|
'stripped' => __('admin.moderation.security_scan.stripped'),
|
||||||
|
'error' => __('admin.moderation.security_scan.error'),
|
||||||
|
default => '—',
|
||||||
|
}),
|
||||||
|
TextEntry::make('security_scan_message')
|
||||||
|
->label(__('admin.moderation.fields.security_scan_message'))
|
||||||
|
->placeholder('—')
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('security_scanned_at')
|
||||||
|
->label(__('admin.moderation.fields.security_scanned_at'))
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Tables;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Columns\ImageColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class PhotosTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
ImageColumn::make('thumbnail_path')
|
||||||
|
->label(__('admin.moderation.table.photo'))
|
||||||
|
->disk('public')
|
||||||
|
->visibility('public')
|
||||||
|
->circular()
|
||||||
|
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path),
|
||||||
|
Tables\Columns\TextColumn::make('event.name')
|
||||||
|
->label(__('admin.moderation.table.event'))
|
||||||
|
->searchable()
|
||||||
|
->limit(30),
|
||||||
|
Tables\Columns\TextColumn::make('event.tenant.name')
|
||||||
|
->label(__('admin.moderation.table.tenant'))
|
||||||
|
->limit(30),
|
||||||
|
Tables\Columns\TextColumn::make('guest_name')
|
||||||
|
->label(__('admin.moderation.table.uploader'))
|
||||||
|
->searchable()
|
||||||
|
->limit(20),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->label(__('admin.moderation.table.status'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state) => match ($state) {
|
||||||
|
'approved' => 'success',
|
||||||
|
'rejected' => 'danger',
|
||||||
|
'hidden' => 'gray',
|
||||||
|
default => 'warning',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (?string $state) => self::statusLabels()[$state] ?? '—')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('security_scan_status')
|
||||||
|
->label(__('admin.moderation.table.security_scan'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state) => match ($state) {
|
||||||
|
'clean', 'skipped', 'stripped' => 'success',
|
||||||
|
'infected' => 'danger',
|
||||||
|
'error' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (?string $state) => self::securityScanLabels()[$state] ?? '—')
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('ingest_source')
|
||||||
|
->label(__('admin.moderation.table.ingest_source'))
|
||||||
|
->badge()
|
||||||
|
->color('gray')
|
||||||
|
->formatStateUsing(fn (?string $state) => self::ingestSourceLabels()[$state] ?? '—')
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label(__('admin.moderation.table.uploaded_at'))
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('moderator.name')
|
||||||
|
->label(__('admin.moderation.table.moderated_by'))
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('moderated_at')
|
||||||
|
->label(__('admin.moderation.table.moderated_at'))
|
||||||
|
->since()
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label(__('admin.moderation.filters.status'))
|
||||||
|
->options(self::statusLabels())
|
||||||
|
->default('pending'),
|
||||||
|
SelectFilter::make('ingest_source')
|
||||||
|
->label(__('admin.moderation.filters.ingest_source'))
|
||||||
|
->options(self::ingestSourceLabels())
|
||||||
|
->default(Photo::SOURCE_GUEST_PWA),
|
||||||
|
SelectFilter::make('security_scan_status')
|
||||||
|
->label(__('admin.moderation.filters.security_scan_status'))
|
||||||
|
->options(self::securityScanLabels()),
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label(__('admin.common.tenant'))
|
||||||
|
->options(fn () => Tenant::query()->orderBy('name')->pluck('name', 'id')->toArray())
|
||||||
|
->searchable(),
|
||||||
|
SelectFilter::make('event_id')
|
||||||
|
->label(__('admin.common.event'))
|
||||||
|
->options(fn () => Event::query()->orderBy('name')->pluck('name', 'id')->toArray())
|
||||||
|
->searchable(),
|
||||||
|
Filter::make('created_at')
|
||||||
|
->label(__('admin.moderation.filters.uploaded_at'))
|
||||||
|
->form([
|
||||||
|
DatePicker::make('from')->label(__('admin.common.from')),
|
||||||
|
DatePicker::make('until')->label(__('admin.common.until')),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
return $query
|
||||||
|
->when(
|
||||||
|
$data['from'] ?? null,
|
||||||
|
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$data['until'] ?? null,
|
||||||
|
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
ViewAction::make(),
|
||||||
|
Action::make('approve')
|
||||||
|
->label(__('admin.moderation.actions.approve'))
|
||||||
|
->color('success')
|
||||||
|
->icon(Heroicon::OutlinedCheckCircle)
|
||||||
|
->visible(fn (Photo $record) => $record->status === 'pending')
|
||||||
|
->form([
|
||||||
|
self::moderationNotesField(false),
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'approved', $data['moderation_notes'] ?? null)),
|
||||||
|
Action::make('reject')
|
||||||
|
->label(__('admin.moderation.actions.reject'))
|
||||||
|
->color('danger')
|
||||||
|
->icon(Heroicon::OutlinedXCircle)
|
||||||
|
->visible(fn (Photo $record) => $record->status === 'pending')
|
||||||
|
->form([
|
||||||
|
self::moderationNotesField(true),
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'rejected', $data['moderation_notes'] ?? null)),
|
||||||
|
Action::make('hide')
|
||||||
|
->label(__('admin.moderation.actions.hide'))
|
||||||
|
->color('gray')
|
||||||
|
->icon(Heroicon::OutlinedEyeSlash)
|
||||||
|
->visible(fn (Photo $record) => $record->status !== 'hidden')
|
||||||
|
->form([
|
||||||
|
self::moderationNotesField(false),
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'hidden', $data['moderation_notes'] ?? null)),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
BulkAction::make('approve')
|
||||||
|
->label(__('admin.moderation.actions.approve_selected'))
|
||||||
|
->icon(Heroicon::OutlinedCheckCircle)
|
||||||
|
->color('success')
|
||||||
|
->form([
|
||||||
|
self::moderationNotesField(false),
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'approved', $data['moderation_notes'] ?? null)),
|
||||||
|
BulkAction::make('reject')
|
||||||
|
->label(__('admin.moderation.actions.reject_selected'))
|
||||||
|
->icon(Heroicon::OutlinedXCircle)
|
||||||
|
->color('danger')
|
||||||
|
->form([
|
||||||
|
self::moderationNotesField(true),
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'rejected', $data['moderation_notes'] ?? null)),
|
||||||
|
BulkAction::make('hide')
|
||||||
|
->label(__('admin.moderation.actions.hide_selected'))
|
||||||
|
->icon(Heroicon::OutlinedEyeSlash)
|
||||||
|
->color('gray')
|
||||||
|
->form([
|
||||||
|
self::moderationNotesField(false),
|
||||||
|
])
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'hidden', $data['moderation_notes'] ?? null)),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function moderationNotesField(bool $required): Textarea
|
||||||
|
{
|
||||||
|
return Textarea::make('moderation_notes')
|
||||||
|
->label(__('admin.moderation.fields.moderation_notes'))
|
||||||
|
->maxLength(1000)
|
||||||
|
->rows(3)
|
||||||
|
->required($required);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyModeration(Photo $record, string $status, ?string $notes): void
|
||||||
|
{
|
||||||
|
$record->update([
|
||||||
|
'status' => $status,
|
||||||
|
'moderation_notes' => $notes,
|
||||||
|
'moderated_at' => now(),
|
||||||
|
'moderated_by' => Filament::auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'photo.'.$status,
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata([
|
||||||
|
'status',
|
||||||
|
'moderation_notes',
|
||||||
|
'moderated_at',
|
||||||
|
'moderated_by',
|
||||||
|
]),
|
||||||
|
source: self::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyModerationToRecords(Collection $records, string $status, ?string $notes): int
|
||||||
|
{
|
||||||
|
$moderatedAt = now();
|
||||||
|
$moderatedBy = Filament::auth()->id();
|
||||||
|
|
||||||
|
$updated = Photo::query()
|
||||||
|
->whereIn('id', $records->pluck('id'))
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update([
|
||||||
|
'status' => $status,
|
||||||
|
'moderation_notes' => $notes,
|
||||||
|
'moderated_at' => $moderatedAt,
|
||||||
|
'moderated_by' => $moderatedBy,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->record(
|
||||||
|
'photo.'.$status,
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata([
|
||||||
|
'status',
|
||||||
|
'moderation_notes',
|
||||||
|
'moderated_at',
|
||||||
|
'moderated_by',
|
||||||
|
]),
|
||||||
|
source: self::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function statusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('admin.moderation.status.pending'),
|
||||||
|
'approved' => __('admin.moderation.status.approved'),
|
||||||
|
'rejected' => __('admin.moderation.status.rejected'),
|
||||||
|
'hidden' => __('admin.moderation.status.hidden'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ingestSourceLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
|
||||||
|
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
|
||||||
|
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
|
||||||
|
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
|
||||||
|
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function securityScanLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => __('admin.moderation.security_scan.pending'),
|
||||||
|
'clean' => __('admin.moderation.security_scan.clean'),
|
||||||
|
'infected' => __('admin.moderation.security_scan.infected'),
|
||||||
|
'skipped' => __('admin.moderation.security_scan.skipped'),
|
||||||
|
'stripped' => __('admin.moderation.security_scan.stripped'),
|
||||||
|
'error' => __('admin.moderation.security_scan.error'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListTenantPaddleHealths extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = TenantPaddleHealthResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TenantPaddleHealthTable
|
||||||
|
{
|
||||||
|
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
|
||||||
|
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label(__('admin.common.tenant'))
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('slug')
|
||||||
|
->label(__('admin.common.slug'))
|
||||||
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('contact_email')
|
||||||
|
->label(__('admin.tenants.fields.contact_email'))
|
||||||
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_customer_id')
|
||||||
|
->label('Paddle customer')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->copyable()
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||||
|
TextColumn::make('subscription_status')
|
||||||
|
->label('Subscription')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state) => match ($state) {
|
||||||
|
'active' => 'success',
|
||||||
|
'suspended' => 'warning',
|
||||||
|
'expired' => 'danger',
|
||||||
|
'free' => 'gray',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextColumn::make('active_reseller_package')
|
||||||
|
->label('Active package')
|
||||||
|
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->name ?? '—')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_subscription_id')
|
||||||
|
->label('Paddle subscription')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->copyable()
|
||||||
|
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||||
|
IconColumn::make('missing_paddle_subscription')
|
||||||
|
->label('Missing Paddle subscription')
|
||||||
|
->boolean()
|
||||||
|
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
|
||||||
|
IconColumn::make('status_mismatch')
|
||||||
|
->label('Status mismatch')
|
||||||
|
->boolean()
|
||||||
|
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||||
|
TextColumn::make('paddle_customer_duplicates')
|
||||||
|
->label('Paddle duplicates')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
|
||||||
|
TextColumn::make('paddle_sync_status')
|
||||||
|
->label('Paddle sync')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state) => match ($state) {
|
||||||
|
'synced' => 'success',
|
||||||
|
'syncing' => 'warning',
|
||||||
|
'pulled' => 'info',
|
||||||
|
'dry-run' => 'gray',
|
||||||
|
'failed', 'pull-failed' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
|
||||||
|
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_synced_at')
|
||||||
|
->label('Paddle synced')
|
||||||
|
->badge()
|
||||||
|
->color(fn ($state) => self::syncAgeColor($state))
|
||||||
|
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
|
||||||
|
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
|
||||||
|
TextColumn::make('last_paddle_transaction_at')
|
||||||
|
->label('Last Paddle tx')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||||
|
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
|
||||||
|
? Carbon::parse($record->last_paddle_transaction_at)
|
||||||
|
: null)
|
||||||
|
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_transaction_count_window')
|
||||||
|
->label('Paddle tx (30d)')
|
||||||
|
->default('0')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('paddle_transaction_total_window')
|
||||||
|
->label('Paddle total (30d)')
|
||||||
|
->default(0)
|
||||||
|
->money('EUR')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('paddle_refund_count_window')
|
||||||
|
->label('Refunds (30d)')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||||
|
->default('0')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_refund_total_window')
|
||||||
|
->label('Refund total (30d)')
|
||||||
|
->default(0)
|
||||||
|
->money('EUR')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_checkout_requires_action_count')
|
||||||
|
->label('Checkout action required')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||||
|
->default('0')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_checkout_processing_count')
|
||||||
|
->label('Checkout processing')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||||
|
->default('0')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_checkout_expired_count')
|
||||||
|
->label('Checkout expired')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||||
|
->default('0')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_transaction_count')
|
||||||
|
->label('Paddle tx (all)')
|
||||||
|
->default('0')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_transaction_total')
|
||||||
|
->label('Paddle total (all)')
|
||||||
|
->default(0)
|
||||||
|
->money('EUR')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Filter::make('missing_paddle_customer')
|
||||||
|
->label('Missing Paddle customer')
|
||||||
|
->indicator('Missing Paddle customer')
|
||||||
|
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
|
||||||
|
Filter::make('missing_paddle_subscription')
|
||||||
|
->label('Missing Paddle subscription')
|
||||||
|
->indicator('Missing Paddle subscription')
|
||||||
|
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
|
||||||
|
->where('active', true)
|
||||||
|
->whereNull('paddle_subscription_id'))),
|
||||||
|
Filter::make('duplicate_paddle_customer')
|
||||||
|
->label('Duplicate Paddle customer')
|
||||||
|
->indicator('Duplicate Paddle customer')
|
||||||
|
->query(fn (Builder $query) => $query
|
||||||
|
->whereNotNull('paddle_customer_id')
|
||||||
|
->whereIn('paddle_customer_id', function ($subquery) {
|
||||||
|
$subquery->select('paddle_customer_id')
|
||||||
|
->from('tenants')
|
||||||
|
->whereNotNull('paddle_customer_id')
|
||||||
|
->groupBy('paddle_customer_id')
|
||||||
|
->havingRaw('count(*) > 1');
|
||||||
|
})),
|
||||||
|
Filter::make('status_mismatch')
|
||||||
|
->label('Status mismatch')
|
||||||
|
->indicator('Status mismatch')
|
||||||
|
->query(fn (Builder $query) => self::applyStatusMismatchFilter($query)),
|
||||||
|
Filter::make('active_package')
|
||||||
|
->label('Active package')
|
||||||
|
->indicator('Active package')
|
||||||
|
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', function (Builder $query) {
|
||||||
|
$query->where('active', true)
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
$query->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>=', now());
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
Filter::make('not_suspended_or_deleted')
|
||||||
|
->label('Not suspended/deleted')
|
||||||
|
->indicator('Not suspended/deleted')
|
||||||
|
->query(fn (Builder $query) => $query
|
||||||
|
->where('is_suspended', false)
|
||||||
|
->whereNull('pending_deletion_at')
|
||||||
|
->whereNull('anonymized_at')),
|
||||||
|
Filter::make('paddle_sync_failed')
|
||||||
|
->label('Paddle sync failed')
|
||||||
|
->indicator('Paddle sync failed')
|
||||||
|
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||||
|
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
|
||||||
|
Filter::make('paddle_sync_stale')
|
||||||
|
->label('Paddle sync stale')
|
||||||
|
->indicator('Paddle sync stale')
|
||||||
|
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||||
|
->whereNotNull('paddle_synced_at')
|
||||||
|
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
|
||||||
|
Filter::make('paddle_sync_missing')
|
||||||
|
->label('Missing Paddle sync timestamp')
|
||||||
|
->indicator('Missing Paddle sync timestamp')
|
||||||
|
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
|
||||||
|
->whereNull('paddle_synced_at'))),
|
||||||
|
Filter::make('paddle_transaction_stale')
|
||||||
|
->label('Stale Paddle transactions')
|
||||||
|
->indicator('Stale Paddle transactions')
|
||||||
|
->query(function (Builder $query): Builder {
|
||||||
|
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
|
||||||
|
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('purchased_at', '>=', $cutoff));
|
||||||
|
}),
|
||||||
|
Filter::make('checkout_attention')
|
||||||
|
->label('Checkout attention')
|
||||||
|
->indicator('Checkout attention')
|
||||||
|
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
||||||
|
$query->where('provider', 'paddle')
|
||||||
|
->where(function (Builder $query) {
|
||||||
|
$query->whereIn('status', [
|
||||||
|
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||||
|
CheckoutSession::STATUS_PROCESSING,
|
||||||
|
])
|
||||||
|
->orWhere(function (Builder $query) {
|
||||||
|
$query->whereNotIn('status', [
|
||||||
|
CheckoutSession::STATUS_COMPLETED,
|
||||||
|
CheckoutSession::STATUS_CANCELLED,
|
||||||
|
])
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<', now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
Filter::make('refund_spike')
|
||||||
|
->label('Refund spike (30d)')
|
||||||
|
->form([
|
||||||
|
TextInput::make('min_refunds')
|
||||||
|
->label('Minimum refunds')
|
||||||
|
->numeric()
|
||||||
|
->default(1)
|
||||||
|
->minValue(1),
|
||||||
|
])
|
||||||
|
->indicateUsing(function (array $data): ?string {
|
||||||
|
$min = (int) ($data['min_refunds'] ?? 0);
|
||||||
|
|
||||||
|
return $min > 0 ? "Refunds >= {$min} (30d)" : null;
|
||||||
|
})
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$min = (int) ($data['min_refunds'] ?? 0);
|
||||||
|
|
||||||
|
if ($min < 1) {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||||
|
|
||||||
|
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', true)
|
||||||
|
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||||
|
}),
|
||||||
|
SelectFilter::make('subscription_status')
|
||||||
|
->label('Subscription')
|
||||||
|
->options([
|
||||||
|
'active' => 'Active',
|
||||||
|
'suspended' => 'Suspended',
|
||||||
|
'expired' => 'Expired',
|
||||||
|
'free' => 'Free',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->actions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasStatusMismatch(Tenant $record): bool
|
||||||
|
{
|
||||||
|
$hasActivePackage = (bool) ($record->has_active_reseller_package ?? $record->activeResellerPackage);
|
||||||
|
$status = (string) ($record->subscription_status ?? '');
|
||||||
|
$expiresAt = $record->subscription_expires_at;
|
||||||
|
|
||||||
|
if ($status === 'active' && ! $hasActivePackage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status !== 'active' && $hasActivePackage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'active' && $expiresAt && $expiresAt->isPast()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function missingPaddleSubscription(Tenant $record): bool
|
||||||
|
{
|
||||||
|
$package = $record->activeResellerPackage;
|
||||||
|
|
||||||
|
return $package && $package->active && ! $package->paddle_subscription_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $query) {
|
||||||
|
$query->where(function (Builder $query) {
|
||||||
|
$query->where('subscription_status', 'active')
|
||||||
|
->whereDoesntHave('activeResellerPackage');
|
||||||
|
})->orWhere(function (Builder $query) {
|
||||||
|
$query->where('subscription_status', '!=', 'active')
|
||||||
|
->whereHas('activeResellerPackage');
|
||||||
|
})->orWhere(function (Builder $query) {
|
||||||
|
$query->where('subscription_status', 'active')
|
||||||
|
->whereNotNull('subscription_expires_at')
|
||||||
|
->where('subscription_expires_at', '<', now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function syncAgeColor($state): string
|
||||||
|
{
|
||||||
|
if (! $state) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function transactionAgeColor(?Carbon $state): string
|
||||||
|
{
|
||||||
|
if (! $state) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
|
||||||
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class TenantPaddleHealthResource extends Resource
|
||||||
|
{
|
||||||
|
public const STALE_SYNC_DAYS = 30;
|
||||||
|
|
||||||
|
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
|
||||||
|
|
||||||
|
protected static ?string $cluster = DailyOpsCluster::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'paddle-health';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return TenantPaddleHealthTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.paddle_health.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.billing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with(['activeResellerPackage.package'])
|
||||||
|
->withExists('activeResellerPackage as has_active_reseller_package')
|
||||||
|
->addSelect([
|
||||||
|
'paddle_customer_duplicates' => Tenant::query()
|
||||||
|
->selectRaw('count(*)')
|
||||||
|
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
|
||||||
|
->whereNotNull('paddle_customer_id'),
|
||||||
|
])
|
||||||
|
->withCount([
|
||||||
|
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', false),
|
||||||
|
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', false)
|
||||||
|
->where('purchased_at', '>=', $windowStart),
|
||||||
|
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', true)
|
||||||
|
->where('purchased_at', '>=', $windowStart),
|
||||||
|
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
|
||||||
|
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||||
|
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||||
|
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
|
||||||
|
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||||
|
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||||
|
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
|
||||||
|
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||||
|
->whereNotIn('status', [
|
||||||
|
CheckoutSession::STATUS_COMPLETED,
|
||||||
|
CheckoutSession::STATUS_CANCELLED,
|
||||||
|
])
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<', now()),
|
||||||
|
])
|
||||||
|
->withSum([
|
||||||
|
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', false),
|
||||||
|
], 'price')
|
||||||
|
->withSum([
|
||||||
|
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', false)
|
||||||
|
->where('purchased_at', '>=', $windowStart),
|
||||||
|
], 'price')
|
||||||
|
->withSum([
|
||||||
|
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle')
|
||||||
|
->where('refunded', true)
|
||||||
|
->where('purchased_at', '>=', $windowStart),
|
||||||
|
], 'price')
|
||||||
|
->withMax([
|
||||||
|
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
|
||||||
|
->where('provider', 'paddle'),
|
||||||
|
], 'purchased_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListTenantPaddleHealths::route('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs\SuperAdminActionLogResource;
|
||||||
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
|
|
||||||
|
class ManageSuperAdminActionLogs extends ManageRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = SuperAdminActionLogResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
|
use App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs\Pages\ManageSuperAdminActionLogs;
|
||||||
|
use App\Models\SuperAdminActionLog;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class SuperAdminActionLogResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = SuperAdminActionLog::class;
|
||||||
|
|
||||||
|
protected static ?string $cluster = RareAdminCluster::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 95;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'action';
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.infrastructure');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return 'Audit log';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDeleteAny(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
//
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('action')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('action')
|
||||||
|
->badge()
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('actor.fullName')
|
||||||
|
->label('Actor')
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('subject_type')
|
||||||
|
->label('Subject')
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ? class_basename($state) : '—')
|
||||||
|
->searchable()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('subject_id')
|
||||||
|
->label('Subject ID')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('metadata')
|
||||||
|
->label('Fields')
|
||||||
|
->formatStateUsing(function ($state): string {
|
||||||
|
if (! is_array($state)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $state['fields'] ?? [];
|
||||||
|
|
||||||
|
return $fields ? implode(', ', $fields) : '—';
|
||||||
|
})
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('source')
|
||||||
|
->label('Source')
|
||||||
|
->limit(40)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('occurred_at')
|
||||||
|
->label('Timestamp')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([])
|
||||||
|
->toolbarActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ManageSuperAdminActionLogs::route('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,17 @@
|
|||||||
namespace App\Filament\Resources\Coupons\Pages;
|
namespace App\Filament\Resources\Coupons\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Coupons\CouponResource;
|
use App\Filament\Resources\Coupons\CouponResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
use App\Jobs\SyncCouponToPaddle;
|
use App\Jobs\SyncCouponToPaddle;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateCoupon extends CreateRecord
|
class CreateCoupon extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = CouponResource::class;
|
protected static string $resource = CouponResource::class;
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
|
parent::afterCreate();
|
||||||
|
|
||||||
SyncCouponToPaddle::dispatch($this->record);
|
SyncCouponToPaddle::dispatch($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
namespace App\Filament\Resources\Coupons\Pages;
|
namespace App\Filament\Resources\Coupons\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Coupons\CouponResource;
|
use App\Filament\Resources\Coupons\CouponResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
use App\Jobs\SyncCouponToPaddle;
|
use App\Jobs\SyncCouponToPaddle;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\ForceDeleteAction;
|
use Filament\Actions\ForceDeleteAction;
|
||||||
use Filament\Actions\RestoreAction;
|
use Filament\Actions\RestoreAction;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditCoupon extends EditRecord
|
class EditCoupon extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = CouponResource::class;
|
protected static string $resource = CouponResource::class;
|
||||||
|
|
||||||
@@ -19,14 +20,34 @@ class EditCoupon extends EditRecord
|
|||||||
return [
|
return [
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
DeleteAction::make()
|
DeleteAction::make()
|
||||||
->after(fn ($record) => SyncCouponToPaddle::dispatch($record, true)),
|
->after(function ($record): void {
|
||||||
ForceDeleteAction::make(),
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
RestoreAction::make(),
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
|
||||||
|
SyncCouponToPaddle::dispatch($record, true);
|
||||||
|
}),
|
||||||
|
ForceDeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'force_deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
|
RestoreAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'restored',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function afterSave(): void
|
protected function afterSave(): void
|
||||||
{
|
{
|
||||||
|
parent::afterSave();
|
||||||
|
|
||||||
SyncCouponToPaddle::dispatch($this->record);
|
SyncCouponToPaddle::dispatch($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\Coupons\Pages;
|
namespace App\Filament\Resources\Coupons\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Coupons\CouponResource;
|
use App\Filament\Resources\Coupons\CouponResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
@@ -13,7 +14,13 @@ class ViewCoupon extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class RedemptionsRelationManager extends RelationManager
|
class RedemptionsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,30 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label(__('Tenant'))
|
->label(__('Tenant'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
TextColumn::make('ip_address')
|
||||||
|
->label(__('IP'))
|
||||||
|
->copyable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('device_id')
|
||||||
|
->label(__('Device'))
|
||||||
|
->copyable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('user_agent')
|
||||||
|
->label(__('User agent'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('fraud_ip')
|
||||||
|
->label(__('IP reputation'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.ip')))
|
||||||
|
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.ip.risk')))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('fraud_device')
|
||||||
|
->label(__('Device reputation'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.device')))
|
||||||
|
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.device.risk')))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('user.name')
|
TextColumn::make('user.name')
|
||||||
->label(__('User'))
|
->label(__('User'))
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
->recordActions([])
|
->recordActions([])
|
||||||
->toolbarActions([]);
|
->toolbarActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{risk?: string, recent_failed?: int, recent_total?: int}|null $snapshot
|
||||||
|
*/
|
||||||
|
private static function formatReputation(?array $snapshot): string
|
||||||
|
{
|
||||||
|
if (! $snapshot) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$risk = Str::headline($snapshot['risk'] ?? 'unknown');
|
||||||
|
$failed = (int) ($snapshot['recent_failed'] ?? 0);
|
||||||
|
$total = (int) ($snapshot['recent_total'] ?? 0);
|
||||||
|
|
||||||
|
return sprintf('%s (%d/%d)', $risk, $failed, $total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function riskColor(?string $risk): string
|
||||||
|
{
|
||||||
|
return match ($risk) {
|
||||||
|
'high' => 'danger',
|
||||||
|
'medium' => 'warning',
|
||||||
|
'low' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Coupons\Schemas;
|
namespace App\Filament\Resources\Coupons\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\CouponStatus;
|
||||||
|
use App\Enums\CouponType;
|
||||||
use Filament\Infolists\Components\KeyValueEntry;
|
use Filament\Infolists\Components\KeyValueEntry;
|
||||||
use Filament\Infolists\Components\Section;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -22,11 +24,11 @@ class CouponInfolist
|
|||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
->label(__('Status'))
|
->label(__('Status'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||||
TextEntry::make('type')
|
TextEntry::make('type')
|
||||||
->label(__('Discount type'))
|
->label(__('Discount type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||||
TextEntry::make('amount')
|
TextEntry::make('amount')
|
||||||
->label(__('Amount'))
|
->label(__('Amount'))
|
||||||
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
|
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
|
||||||
@@ -78,4 +80,21 @@ class CouponInfolist
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function formatEnumState(mixed $state): string
|
||||||
|
{
|
||||||
|
if ($state instanceof CouponType || $state instanceof CouponStatus) {
|
||||||
|
return $state->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state instanceof \BackedEnum) {
|
||||||
|
return Str::headline($state->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($state)) {
|
||||||
|
return Str::headline($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources\Coupons\Tables;
|
|||||||
use App\Enums\CouponStatus;
|
use App\Enums\CouponStatus;
|
||||||
use App\Enums\CouponType;
|
use App\Enums\CouponType;
|
||||||
use App\Jobs\SyncCouponToPaddle;
|
use App\Jobs\SyncCouponToPaddle;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -18,6 +19,7 @@ use Filament\Tables\Filters\SelectFilter;
|
|||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class CouponsTable
|
class CouponsTable
|
||||||
@@ -38,7 +40,7 @@ class CouponsTable
|
|||||||
TextColumn::make('type')
|
TextColumn::make('type')
|
||||||
->label(__('Type'))
|
->label(__('Type'))
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state))
|
->formatStateUsing(fn ($state) => static::formatEnumState($state))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('amount')
|
TextColumn::make('amount')
|
||||||
->label(__('Amount'))
|
->label(__('Amount'))
|
||||||
@@ -57,7 +59,7 @@ class CouponsTable
|
|||||||
->label(__('Status'))
|
->label(__('Status'))
|
||||||
->badge()
|
->badge()
|
||||||
->sortable()
|
->sortable()
|
||||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
|
||||||
TextColumn::make('starts_at')
|
TextColumn::make('starts_at')
|
||||||
->label(__('Starts'))
|
->label(__('Starts'))
|
||||||
->date()
|
->date()
|
||||||
@@ -95,7 +97,13 @@ class CouponsTable
|
|||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
Action::make('sync')
|
Action::make('sync')
|
||||||
->label(__('Sync to Paddle'))
|
->label(__('Sync to Paddle'))
|
||||||
->icon('heroicon-m-arrow-path')
|
->icon('heroicon-m-arrow-path')
|
||||||
@@ -104,10 +112,60 @@ class CouponsTable
|
|||||||
])
|
])
|
||||||
->toolbarActions([
|
->toolbarActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
ForceDeleteBulkAction::make(),
|
->after(function (Collection $records): void {
|
||||||
RestoreBulkAction::make(),
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ForceDeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'force_deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RestoreBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'restored',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function formatEnumState(mixed $state): string
|
||||||
|
{
|
||||||
|
if ($state instanceof CouponType || $state instanceof CouponStatus) {
|
||||||
|
return $state->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state instanceof \BackedEnum) {
|
||||||
|
return Str::headline($state->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($state)) {
|
||||||
|
return Str::headline($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
app/Filament/Resources/DataExportResource.php
Normal file
78
app/Filament/Resources/DataExportResource.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
|
use App\Filament\Resources\DataExportResource\Pages\CreateDataExport;
|
||||||
|
use App\Filament\Resources\DataExportResource\Pages\ListDataExports;
|
||||||
|
use App\Filament\Resources\DataExportResource\Schemas\DataExportForm;
|
||||||
|
use App\Filament\Resources\DataExportResource\Tables\DataExportTable;
|
||||||
|
use App\Models\DataExport;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class DataExportResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = DataExport::class;
|
||||||
|
|
||||||
|
protected static ?string $cluster = RareAdminCluster::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowDownTray;
|
||||||
|
|
||||||
|
protected static UnitEnum|string|null $navigationGroup = null;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 50;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return DataExportForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return DataExportTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.data_exports.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()->with(['tenant', 'event', 'user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListDataExports::route('/'),
|
||||||
|
'create' => CreateDataExport::route('/create'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDeleteAny(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DataExportResource\Pages;
|
||||||
|
|
||||||
|
use App\Enums\DataExportScope;
|
||||||
|
use App\Filament\Resources\DataExportResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
use App\Jobs\GenerateDataExport;
|
||||||
|
use App\Models\DataExport;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
class CreateDataExport extends AuditedCreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DataExportResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['user_id'] = Filament::auth()->id();
|
||||||
|
$data['status'] = DataExport::STATUS_PENDING;
|
||||||
|
|
||||||
|
if (($data['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
||||||
|
$data['event_id'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
parent::afterCreate();
|
||||||
|
|
||||||
|
GenerateDataExport::dispatch($this->record->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DataExportResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DataExportResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListDataExports extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = DataExportResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label(__('admin.data_exports.actions.request')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DataExportResource\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\DataExportScope;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Get;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class DataExportForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->schema([
|
||||||
|
Section::make(__('admin.data_exports.sections.request'))
|
||||||
|
->schema([
|
||||||
|
Select::make('scope')
|
||||||
|
->label(__('admin.data_exports.fields.scope'))
|
||||||
|
->options([
|
||||||
|
DataExportScope::TENANT->value => __('admin.data_exports.scope.tenant'),
|
||||||
|
DataExportScope::EVENT->value => __('admin.data_exports.scope.event'),
|
||||||
|
])
|
||||||
|
->default(DataExportScope::TENANT->value)
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
Select::make('tenant_id')
|
||||||
|
->label(__('admin.data_exports.fields.tenant'))
|
||||||
|
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->required()
|
||||||
|
->live(),
|
||||||
|
Select::make('event_id')
|
||||||
|
->label(__('admin.data_exports.fields.event'))
|
||||||
|
->options(function (Get $get): array {
|
||||||
|
$tenantId = $get('tenant_id');
|
||||||
|
if (! $tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Event::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->orderByDesc('date')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function (Event $event): array {
|
||||||
|
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
|
||||||
|
|
||||||
|
return [$event->id => $name];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->visible(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
|
||||||
|
->required(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
|
||||||
|
->dehydrated(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value),
|
||||||
|
Toggle::make('include_media')
|
||||||
|
->label(__('admin.data_exports.fields.include_media'))
|
||||||
|
->helperText(__('admin.data_exports.help.include_media')),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DataExportResource\Tables;
|
||||||
|
|
||||||
|
use App\Enums\DataExportScope;
|
||||||
|
use App\Jobs\GenerateDataExport;
|
||||||
|
use App\Models\DataExport;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
|
||||||
|
class DataExportTable
|
||||||
|
{
|
||||||
|
public static function formatScope(DataExportScope|string|null $state): string
|
||||||
|
{
|
||||||
|
if ($state instanceof DataExportScope) {
|
||||||
|
$state = $state->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state ? __('admin.data_exports.scope.'.$state) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('id')
|
||||||
|
->label(__('admin.data_exports.fields.id'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label(__('admin.data_exports.fields.tenant'))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('event.slug')
|
||||||
|
->label(__('admin.data_exports.fields.event'))
|
||||||
|
->toggleable()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('scope')
|
||||||
|
->label(__('admin.data_exports.fields.scope'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (DataExportScope|string|null $state): string => self::formatScope($state)),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label(__('admin.data_exports.fields.status'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (string $state) => __('admin.data_exports.status.'.$state))
|
||||||
|
->color(fn (string $state) => match ($state) {
|
||||||
|
DataExport::STATUS_READY => 'success',
|
||||||
|
DataExport::STATUS_FAILED => 'danger',
|
||||||
|
DataExport::STATUS_PROCESSING => 'warning',
|
||||||
|
DataExport::STATUS_CANCELED => 'gray',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
IconColumn::make('include_media')
|
||||||
|
->label(__('admin.data_exports.fields.include_media'))
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('size_bytes')
|
||||||
|
->label(__('admin.data_exports.fields.size'))
|
||||||
|
->formatStateUsing(fn (?int $state) => $state ? Number::fileSize($state) : '—')
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label(__('admin.data_exports.fields.created_at'))
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('expires_at')
|
||||||
|
->label(__('admin.data_exports.fields.expires_at'))
|
||||||
|
->since()
|
||||||
|
->toggleable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('scope')
|
||||||
|
->label(__('admin.data_exports.fields.scope'))
|
||||||
|
->options([
|
||||||
|
'tenant' => __('admin.data_exports.scope.tenant'),
|
||||||
|
'event' => __('admin.data_exports.scope.event'),
|
||||||
|
'user' => __('admin.data_exports.scope.user'),
|
||||||
|
]),
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label(__('admin.data_exports.fields.status'))
|
||||||
|
->options([
|
||||||
|
DataExport::STATUS_PENDING => __('admin.data_exports.status.pending'),
|
||||||
|
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
|
||||||
|
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
|
||||||
|
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
|
||||||
|
DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('download')
|
||||||
|
->label(__('admin.data_exports.actions.download'))
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()),
|
||||||
|
Action::make('retry')
|
||||||
|
->label(__('admin.data_exports.actions.retry'))
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (DataExport $record): bool => $record->canRetry())
|
||||||
|
->action(function (DataExport $record): void {
|
||||||
|
if (! $record->canRetry()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->resetForRetry();
|
||||||
|
GenerateDataExport::dispatch($record->id);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
source: self::class
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Action::make('cancel')
|
||||||
|
->label(__('admin.data_exports.actions.cancel'))
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (DataExport $record): bool => $record->canCancel())
|
||||||
|
->action(function (DataExport $record): void {
|
||||||
|
if (! $record->canCancel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->markCanceled();
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
source: self::class
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
|
|||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\EmotionResource\Pages;
|
use App\Filament\Resources\EmotionResource\Pages;
|
||||||
use App\Models\Emotion;
|
use App\Models\Emotion;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\MarkdownEditor;
|
use Filament\Forms\Components\MarkdownEditor;
|
||||||
@@ -17,6 +18,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EmotionResource extends Resource
|
class EmotionResource extends Resource
|
||||||
@@ -116,10 +118,27 @@ class EmotionResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, Emotion $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\EmotionResource\Pages;
|
namespace App\Filament\Resources\EmotionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EmotionResource;
|
use App\Filament\Resources\EmotionResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ManageRecords;
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
|
|
||||||
@@ -13,7 +14,13 @@ class ManageEmotions extends ManageRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'created',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
Actions\Action::make('import')
|
Actions\Action::make('import')
|
||||||
->label(__('admin.common.import_csv'))
|
->label(__('admin.common.import_csv'))
|
||||||
->icon('heroicon-o-arrow-up-tray')
|
->icon('heroicon-o-arrow-up-tray')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Exports\EventPurchaseExporter;
|
|||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\EventPurchaseResource\Pages;
|
use App\Filament\Resources\EventPurchaseResource\Pages;
|
||||||
use App\Models\EventPurchase;
|
use App\Models\EventPurchase;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -23,6 +24,7 @@ use Filament\Tables\Filters\SelectFilter;
|
|||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class EventPurchaseResource extends Resource
|
class EventPurchaseResource extends Resource
|
||||||
@@ -174,11 +176,29 @@ class EventPurchaseResource extends Resource
|
|||||||
->action(function (EventPurchase $record) {
|
->action(function (EventPurchase $record) {
|
||||||
$record->update(['refunded_at' => now()]);
|
$record->update(['refunded_at' => now()]);
|
||||||
Log::info('Refund processed for purchase ID: '.$record->id);
|
Log::info('Refund processed for purchase ID: '.$record->id);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'event_purchase.refunded',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['refunded_at']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
ExportBulkAction::make()
|
ExportBulkAction::make()
|
||||||
->label('Export CSV')
|
->label('Export CSV')
|
||||||
->exporter(EventPurchaseExporter::class),
|
->exporter(EventPurchaseExporter::class),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\EventPurchaseResource\Pages;
|
namespace App\Filament\Resources\EventPurchaseResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventPurchaseResource;
|
use App\Filament\Resources\EventPurchaseResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreateEventPurchase extends CreateRecord
|
class CreateEventPurchase extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EventPurchaseResource::class;
|
protected static string $resource = EventPurchaseResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace App\Filament\Resources\EventPurchaseResource\Pages;
|
namespace App\Filament\Resources\EventPurchaseResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventPurchaseResource;
|
use App\Filament\Resources\EventPurchaseResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditEventPurchase extends EditRecord
|
class EditEventPurchase extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EventPurchaseResource::class;
|
protected static string $resource = EventPurchaseResource::class;
|
||||||
|
|
||||||
@@ -14,7 +15,12 @@ class EditEventPurchase extends EditRecord
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\EventPurchaseResource\Pages;
|
namespace App\Filament\Resources\EventPurchaseResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventPurchaseResource;
|
use App\Filament\Resources\EventPurchaseResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
@@ -13,7 +14,13 @@ class ViewEventPurchase extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Event;
|
|||||||
use App\Models\EventJoinTokenEvent;
|
use App\Models\EventJoinTokenEvent;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -22,6 +23,7 @@ use Filament\Resources\Resource;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EventResource extends Resource
|
class EventResource extends Resource
|
||||||
@@ -133,11 +135,26 @@ class EventResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, Event $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
Actions\Action::make('toggle')
|
Actions\Action::make('toggle')
|
||||||
->label(__('admin.events.actions.toggle_active'))
|
->label(__('admin.events.actions.toggle_active'))
|
||||||
->icon('heroicon-o-power')
|
->icon('heroicon-o-power')
|
||||||
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
|
->action(function (Event $record): void {
|
||||||
|
$record->update(['is_active' => ! $record->is_active]);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'event.toggled',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}),
|
||||||
Actions\Action::make('download_photos')
|
Actions\Action::make('download_photos')
|
||||||
->label(__('admin.events.actions.download_photos'))
|
->label(__('admin.events.actions.download_photos'))
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
@@ -243,7 +260,18 @@ class EventResource extends Resource
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\EventResource\Pages;
|
namespace App\Filament\Resources\EventResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventResource;
|
use App\Filament\Resources\EventResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreateEvent extends CreateRecord
|
class CreateEvent extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EventResource::class;
|
protected static string $resource = EventResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\EventResource\Pages;
|
namespace App\Filament\Resources\EventResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventResource;
|
use App\Filament\Resources\EventResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
|
||||||
class EditEvent extends EditRecord
|
class EditEvent extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EventResource::class;
|
protected static string $resource = EventResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,41 @@
|
|||||||
namespace App\Filament\Resources\EventResource\Pages;
|
namespace App\Filament\Resources\EventResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventResource;
|
use App\Filament\Resources\EventResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
|
||||||
use Filament\Resources\Pages\Page;
|
use Filament\Resources\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class ManageWatermark extends Page
|
class ManageWatermark extends Page
|
||||||
{
|
{
|
||||||
|
use InteractsWithRecord;
|
||||||
|
|
||||||
protected static string $resource = EventResource::class;
|
protected static string $resource = EventResource::class;
|
||||||
|
|
||||||
protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
|
protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
|
||||||
|
|
||||||
public ?string $watermark_mode = 'base';
|
public ?string $watermark_mode = 'base';
|
||||||
|
|
||||||
public ?string $watermark_asset = null;
|
public ?string $watermark_asset = null;
|
||||||
|
|
||||||
public string $watermark_position = 'bottom-right';
|
public string $watermark_position = 'bottom-right';
|
||||||
|
|
||||||
public float $watermark_opacity = 0.25;
|
public float $watermark_opacity = 0.25;
|
||||||
|
|
||||||
public float $watermark_scale = 0.2;
|
public float $watermark_scale = 0.2;
|
||||||
|
|
||||||
public int $watermark_padding = 16;
|
public int $watermark_padding = 16;
|
||||||
|
|
||||||
public bool $serve_originals = false;
|
public bool $serve_originals = false;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(int|string $record): void
|
||||||
{
|
{
|
||||||
|
$this->record = $this->resolveRecord($record);
|
||||||
|
|
||||||
$event = $this->record;
|
$event = $this->record;
|
||||||
$settings = $event->settings ?? [];
|
$settings = $event->settings ?? [];
|
||||||
$watermark = Arr::get($settings, 'watermark', []);
|
$watermark = Arr::get($settings, 'watermark', []);
|
||||||
@@ -37,67 +51,62 @@ class ManageWatermark extends Page
|
|||||||
$this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false);
|
$this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getForms(): array
|
public function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return [
|
return $schema->schema([
|
||||||
'form' => $this->form(
|
Section::make(__('filament-watermark.heading'))
|
||||||
$this->makeForm()
|
->schema([
|
||||||
->schema([
|
Forms\Components\Select::make('watermark_mode')
|
||||||
Forms\Components\Fieldset::make(__('filament-watermark.heading'))
|
->label(__('filament-watermark.mode.label'))
|
||||||
->schema([
|
->options([
|
||||||
Forms\Components\Select::make('watermark_mode')
|
'base' => __('filament-watermark.mode.base'),
|
||||||
->label(__('filament-watermark.mode.label'))
|
'custom' => __('filament-watermark.mode.custom'),
|
||||||
->options([
|
'off' => __('filament-watermark.mode.off'),
|
||||||
'base' => __('filament-watermark.mode.base'),
|
])
|
||||||
'custom' => __('filament-watermark.mode.custom'),
|
->required(),
|
||||||
'off' => __('filament-watermark.mode.off'),
|
Forms\Components\FileUpload::make('watermark_asset')
|
||||||
])
|
->label(__('filament-watermark.asset'))
|
||||||
->required(),
|
->disk('public')
|
||||||
Forms\Components\FileUpload::make('watermark_asset')
|
->directory('branding')
|
||||||
->label(__('filament-watermark.asset'))
|
->preserveFilenames()
|
||||||
->disk('public')
|
->image()
|
||||||
->directory('branding')
|
->visible(fn (callable $get) => $get('watermark_mode') === 'custom'),
|
||||||
->preserveFilenames()
|
Forms\Components\Select::make('watermark_position')
|
||||||
->image()
|
->label(__('filament-watermark.position'))
|
||||||
->visible(fn (callable $get) => $get('watermark_mode') === 'custom'),
|
->options([
|
||||||
Forms\Components\Select::make('watermark_position')
|
'top-left' => 'Top Left',
|
||||||
->label(__('filament-watermark.position'))
|
'top-right' => 'Top Right',
|
||||||
->options([
|
'bottom-left' => 'Bottom Left',
|
||||||
'top-left' => 'Top Left',
|
'bottom-right' => 'Bottom Right',
|
||||||
'top-right' => 'Top Right',
|
'center' => 'Center',
|
||||||
'bottom-left' => 'Bottom Left',
|
])
|
||||||
'bottom-right' => 'Bottom Right',
|
->required(),
|
||||||
'center' => 'Center',
|
Forms\Components\TextInput::make('watermark_opacity')
|
||||||
])
|
->label(__('filament-watermark.opacity'))
|
||||||
->required(),
|
->numeric()
|
||||||
Forms\Components\TextInput::make('watermark_opacity')
|
->minValue(0)
|
||||||
->label(__('filament-watermark.opacity'))
|
->maxValue(1)
|
||||||
->numeric()
|
->step(0.05)
|
||||||
->minValue(0)
|
->required(),
|
||||||
->maxValue(1)
|
Forms\Components\TextInput::make('watermark_scale')
|
||||||
->step(0.05)
|
->label(__('filament-watermark.scale'))
|
||||||
->required(),
|
->numeric()
|
||||||
Forms\Components\TextInput::make('watermark_scale')
|
->minValue(0.05)
|
||||||
->label(__('filament-watermark.scale'))
|
->maxValue(1)
|
||||||
->numeric()
|
->step(0.05)
|
||||||
->minValue(0.05)
|
->required(),
|
||||||
->maxValue(1)
|
Forms\Components\TextInput::make('watermark_padding')
|
||||||
->step(0.05)
|
->label(__('filament-watermark.padding'))
|
||||||
->required(),
|
->numeric()
|
||||||
Forms\Components\TextInput::make('watermark_padding')
|
->minValue(0)
|
||||||
->label(__('filament-watermark.padding'))
|
->required(),
|
||||||
->numeric()
|
Forms\Components\Toggle::make('serve_originals')
|
||||||
->minValue(0)
|
->label(__('filament-watermark.serve_originals'))
|
||||||
->required(),
|
->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.')
|
||||||
Forms\Components\Toggle::make('serve_originals')
|
->default(false),
|
||||||
->label(__('filament-watermark.serve_originals'))
|
])
|
||||||
->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.')
|
->columns(2),
|
||||||
->default(false),
|
]);
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(): void
|
public function save(): void
|
||||||
@@ -133,6 +142,17 @@ class ManageWatermark extends Page
|
|||||||
|
|
||||||
$event->forceFill(['settings' => $settings])->save();
|
$event->forceFill(['settings' => $settings])->save();
|
||||||
|
|
||||||
|
$changed = array_diff(array_keys($event->getChanges()), ['updated_at']);
|
||||||
|
|
||||||
|
if ($changed !== []) {
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'event.watermark_updated',
|
||||||
|
$event,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($changed),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('filament-watermark.saved'))
|
->title(__('filament-watermark.saved'))
|
||||||
->success()
|
->success()
|
||||||
|
|||||||
@@ -3,21 +3,21 @@
|
|||||||
namespace App\Filament\Resources\EventResource\RelationManagers;
|
namespace App\Filament\Resources\EventResource\RelationManagers;
|
||||||
|
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
|
||||||
@@ -93,15 +93,43 @@ class EventPackagesRelationManager extends RelationManager
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
CreateAction::make(),
|
CreateAction::make()
|
||||||
|
->after(fn (array $data, EventPackage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'created',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
DeleteAction::make(),
|
->after(fn (array $data, EventPackage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
|
DeleteAction::make()
|
||||||
|
->after(fn (EventPackage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
|
|||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\EventTypeResource\Pages;
|
use App\Filament\Resources\EventTypeResource\Pages;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
@@ -16,6 +17,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EventTypeResource extends Resource
|
class EventTypeResource extends Resource
|
||||||
@@ -104,10 +106,27 @@ class EventTypeResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\EventTypeResource\Pages;
|
namespace App\Filament\Resources\EventTypeResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EventTypeResource;
|
use App\Filament\Resources\EventTypeResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ManageRecords;
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
|
|
||||||
@@ -13,7 +14,13 @@ class ManageEventTypes extends ManageRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'created',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
|
|||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\GiftVoucherResource\Pages;
|
use App\Filament\Resources\GiftVoucherResource\Pages;
|
||||||
use App\Models\GiftVoucher;
|
use App\Models\GiftVoucher;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Services\GiftVouchers\GiftVoucherService;
|
use App\Services\GiftVouchers\GiftVoucherService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -97,6 +98,13 @@ class GiftVoucherResource extends Resource
|
|||||||
->visible(fn (GiftVoucher $record): bool => $record->canBeRefunded())
|
->visible(fn (GiftVoucher $record): bool => $record->canBeRefunded())
|
||||||
->action(function (GiftVoucher $record, GiftVoucherService $service): void {
|
->action(function (GiftVoucher $record, GiftVoucherService $service): void {
|
||||||
$service->refund($record, 'customer_request');
|
$service->refund($record, 'customer_request');
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'gift_voucher.refunded',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['status', 'refunded_at']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
})
|
})
|
||||||
->successNotificationTitle('Gutschein erstattet'),
|
->successNotificationTitle('Gutschein erstattet'),
|
||||||
Action::make('resend')
|
Action::make('resend')
|
||||||
@@ -118,6 +126,13 @@ class GiftVoucherResource extends Resource
|
|||||||
$record,
|
$record,
|
||||||
Carbon::parse($data['recipient_delivery_scheduled_at'])
|
Carbon::parse($data['recipient_delivery_scheduled_at'])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'gift_voucher.delivery_scheduled',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
})
|
})
|
||||||
->visible(fn (GiftVoucher $record): bool => ! empty($record->recipient_email)),
|
->visible(fn (GiftVoucher $record): bool => ! empty($record->recipient_email)),
|
||||||
Action::make('mark_redeemed')
|
Action::make('mark_redeemed')
|
||||||
@@ -136,6 +151,13 @@ class GiftVoucherResource extends Resource
|
|||||||
'manual_marked' => true,
|
'manual_marked' => true,
|
||||||
]),
|
]),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'gift_voucher.marked_redeemed',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['status', 'redeemed_at', 'metadata']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
})
|
})
|
||||||
->successNotificationTitle('Als eingelöst markiert'),
|
->successNotificationTitle('Als eingelöst markiert'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
namespace App\Filament\Resources\GiftVoucherResource\Pages;
|
namespace App\Filament\Resources\GiftVoucherResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\GiftVoucherResource;
|
use App\Filament\Resources\GiftVoucherResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Services\GiftVouchers\GiftVoucherService;
|
use App\Services\GiftVouchers\GiftVoucherService;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ListGiftVouchers extends ListRecords
|
class ListGiftVouchers extends ListRecords
|
||||||
@@ -62,7 +63,20 @@ class ListGiftVouchers extends ListRecords
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$service->issueFromPaddle($payload);
|
$voucher = $service->issueFromPaddle($payload);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'issued',
|
||||||
|
$voucher,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata([
|
||||||
|
'amount',
|
||||||
|
'currency',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'coupon_id',
|
||||||
|
]),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
})
|
})
|
||||||
->modalHeading('Geschenkgutschein ausstellen'),
|
->modalHeading('Geschenkgutschein ausstellen'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
|
|||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
use App\Filament\Resources\LegalPageResource\Pages;
|
use App\Filament\Resources\LegalPageResource\Pages;
|
||||||
use App\Models\LegalPage;
|
use App\Models\LegalPage;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
@@ -18,6 +19,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class LegalPageResource extends Resource
|
class LegalPageResource extends Resource
|
||||||
@@ -99,10 +101,27 @@ class LegalPageResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, LegalPage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\LegalPageResource\Pages;
|
namespace App\Filament\Resources\LegalPageResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\LegalPageResource;
|
use App\Filament\Resources\LegalPageResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
|
||||||
class EditLegalPage extends EditRecord
|
class EditLegalPage extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = LegalPageResource::class;
|
protected static string $resource = LegalPageResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
|
|||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
use App\Filament\Resources\MediaStorageTargetResource\Pages;
|
use App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||||
use App\Models\MediaStorageTarget;
|
use App\Models\MediaStorageTarget;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
@@ -15,6 +16,7 @@ use Filament\Resources\Resource;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class MediaStorageTargetResource extends Resource
|
class MediaStorageTargetResource extends Resource
|
||||||
@@ -115,10 +117,27 @@ class MediaStorageTargetResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, MediaStorageTarget $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\MediaStorageTargetResource;
|
use App\Filament\Resources\MediaStorageTargetResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreateMediaStorageTarget extends CreateRecord
|
class CreateMediaStorageTarget extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = MediaStorageTargetResource::class;
|
protected static string $resource = MediaStorageTargetResource::class;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,23 @@
|
|||||||
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\MediaStorageTargetResource;
|
use App\Filament\Resources\MediaStorageTargetResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditMediaStorageTarget extends EditRecord
|
class EditMediaStorageTarget extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = MediaStorageTargetResource::class;
|
protected static string $resource = MediaStorageTargetResource::class;
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
|||||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||||
use App\Jobs\SyncPackageAddonToPaddle;
|
use App\Jobs\SyncPackageAddonToPaddle;
|
||||||
use App\Models\PackageAddon;
|
use App\Models\PackageAddon;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
@@ -17,6 +18,7 @@ use Filament\Tables;
|
|||||||
use Filament\Tables\Columns\BadgeColumn;
|
use Filament\Tables\Columns\BadgeColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class PackageAddonResource extends Resource
|
class PackageAddonResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -130,11 +132,28 @@ class PackageAddonResource extends Resource
|
|||||||
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, PackageAddon $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkActionGroup::make([
|
Actions\BulkActionGroup::make([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PackageAddonResource;
|
use App\Filament\Resources\PackageAddonResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreatePackageAddon extends CreateRecord
|
class CreatePackageAddon extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PackageAddonResource::class;
|
protected static string $resource = PackageAddonResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
namespace App\Filament\Resources\PackageAddonResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PackageAddonResource;
|
use App\Filament\Resources\PackageAddonResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
|
||||||
class EditPackageAddon extends EditRecord
|
class EditPackageAddon extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PackageAddonResource::class;
|
protected static string $resource = PackageAddonResource::class;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Filament\Resources\PackageResource\Pages;
|
|||||||
use App\Jobs\PullPackageFromPaddle;
|
use App\Jobs\PullPackageFromPaddle;
|
||||||
use App\Jobs\SyncPackageToPaddle;
|
use App\Jobs\SyncPackageToPaddle;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -37,6 +38,7 @@ use Filament\Tables\Columns\TextColumn;
|
|||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules\Unique;
|
use Illuminate\Validation\Rules\Unique;
|
||||||
@@ -191,6 +193,11 @@ class PackageResource extends Resource
|
|||||||
->label('Zuletzt synchronisiert')
|
->label('Zuletzt synchronisiert')
|
||||||
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('paddle_sync_error')
|
||||||
|
->label('Letzter Fehler')
|
||||||
|
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–')
|
||||||
|
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
|
||||||
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -269,7 +276,7 @@ class PackageResource extends Resource
|
|||||||
->colors([
|
->colors([
|
||||||
'success' => 'synced',
|
'success' => 'synced',
|
||||||
'warning' => 'syncing',
|
'warning' => 'syncing',
|
||||||
'info' => 'dry-run',
|
'info' => ['dry-run', 'linked', 'pulled'],
|
||||||
'danger' => ['failed', 'pull-failed'],
|
'danger' => ['failed', 'pull-failed'],
|
||||||
])
|
])
|
||||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||||
@@ -278,6 +285,11 @@ class PackageResource extends Resource
|
|||||||
->label('Sync am')
|
->label('Sync am')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_sync_error_message')
|
||||||
|
->label('Sync-Fehler')
|
||||||
|
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
|
||||||
|
->wrap()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('type')
|
Tables\Filters\SelectFilter::make('type')
|
||||||
@@ -304,6 +316,42 @@ class PackageResource extends Resource
|
|||||||
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
|
Actions\Action::make('linkPaddle')
|
||||||
|
->label('Paddle verknüpfen')
|
||||||
|
->icon('heroicon-o-link')
|
||||||
|
->color('info')
|
||||||
|
->form([
|
||||||
|
TextInput::make('paddle_product_id')
|
||||||
|
->label('Paddle Produkt-ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(191),
|
||||||
|
TextInput::make('paddle_price_id')
|
||||||
|
->label('Paddle Preis-ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(191),
|
||||||
|
])
|
||||||
|
->fillForm(fn (Package $record) => [
|
||||||
|
'paddle_product_id' => $record->paddle_product_id,
|
||||||
|
'paddle_price_id' => $record->paddle_price_id,
|
||||||
|
])
|
||||||
|
->action(function (Package $record, array $data): void {
|
||||||
|
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
|
||||||
|
|
||||||
|
PullPackageFromPaddle::dispatch($record->id);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'linked',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Paddle-Verknüpfung gespeichert')
|
||||||
|
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
Actions\Action::make('pullPaddle')
|
Actions\Action::make('pullPaddle')
|
||||||
->label('Status von Paddle holen')
|
->label('Status von Paddle holen')
|
||||||
->icon('heroicon-o-cloud-arrow-down')
|
->icon('heroicon-o-cloud-arrow-down')
|
||||||
@@ -319,20 +367,75 @@ class PackageResource extends Resource
|
|||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
|
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
DeleteAction::make()
|
DeleteAction::make()
|
||||||
->visible(fn (Package $record) => ! $record->trashed()),
|
->visible(fn (Package $record) => ! $record->trashed())
|
||||||
|
->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
RestoreAction::make()
|
RestoreAction::make()
|
||||||
->visible(fn (Package $record) => $record->trashed()),
|
->visible(fn (Package $record) => $record->trashed())
|
||||||
|
->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'restored',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
ForceDeleteAction::make()
|
ForceDeleteAction::make()
|
||||||
->visible(fn (Package $record) => $record->trashed())
|
->visible(fn (Package $record) => $record->trashed())
|
||||||
->requiresConfirmation(),
|
->requiresConfirmation()
|
||||||
|
->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'force_deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
RestoreBulkAction::make(),
|
->after(function (Collection $records): void {
|
||||||
ForceDeleteBulkAction::make()->requiresConfirmation(),
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RestoreBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'restored',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ForceDeleteBulkAction::make()
|
||||||
|
->requiresConfirmation()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'force_deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
namespace App\Filament\Resources\PackageResource\Pages;
|
namespace App\Filament\Resources\PackageResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PackageResource;
|
use App\Filament\Resources\PackageResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
|
||||||
class CreatePackage extends CreateRecord
|
class CreatePackage extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PackageResource::class;
|
protected static string $resource = PackageResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace App\Filament\Resources\PackageResource\Pages;
|
namespace App\Filament\Resources\PackageResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PackageResource;
|
use App\Filament\Resources\PackageResource;
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditPackage extends EditRecord
|
class EditPackage extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PackageResource::class;
|
protected static string $resource = PackageResource::class;
|
||||||
|
|
||||||
@@ -14,7 +15,12 @@ class EditPackage extends EditRecord
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/Filament/Resources/Pages/AuditedCreateRecord.php
Normal file
19
app/Filament/Resources/Pages/AuditedCreateRecord.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Pages;
|
||||||
|
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class AuditedCreateRecord extends CreateRecord
|
||||||
|
{
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'created',
|
||||||
|
$this->record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($this->form->getState()),
|
||||||
|
static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Filament/Resources/Pages/AuditedEditRecord.php
Normal file
25
app/Filament/Resources/Pages/AuditedEditRecord.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Pages;
|
||||||
|
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class AuditedEditRecord extends EditRecord
|
||||||
|
{
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
$changed = array_keys($this->record->getChanges());
|
||||||
|
|
||||||
|
if ($changed === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$this->record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($changed ?: $this->form->getState()),
|
||||||
|
static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
|||||||
use App\Filament\Resources\PhotoResource\Pages;
|
use App\Filament\Resources\PhotoResource\Pages;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\FileUpload;
|
use Filament\Forms\Components\FileUpload;
|
||||||
@@ -16,6 +17,7 @@ use Filament\Resources\Resource;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PhotoResource extends Resource
|
class PhotoResource extends Resource
|
||||||
@@ -78,29 +80,95 @@ class PhotoResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, Photo $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
Actions\Action::make('feature')
|
Actions\Action::make('feature')
|
||||||
->label(__('admin.photos.actions.feature'))
|
->label(__('admin.photos.actions.feature'))
|
||||||
->visible(fn (Photo $record) => ! $record->is_featured)
|
->visible(fn (Photo $record) => ! $record->is_featured)
|
||||||
->action(fn (Photo $record) => $record->update(['is_featured' => true]))
|
->action(function (Photo $record): void {
|
||||||
|
$record->update(['is_featured' => true]);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'photo.featured',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
})
|
||||||
->icon('heroicon-o-star'),
|
->icon('heroicon-o-star'),
|
||||||
Actions\Action::make('unfeature')
|
Actions\Action::make('unfeature')
|
||||||
->label(__('admin.photos.actions.unfeature'))
|
->label(__('admin.photos.actions.unfeature'))
|
||||||
->visible(fn (Photo $record) => $record->is_featured)
|
->visible(fn (Photo $record) => $record->is_featured)
|
||||||
->action(fn (Photo $record) => $record->update(['is_featured' => false]))
|
->action(function (Photo $record): void {
|
||||||
|
$record->update(['is_featured' => false]);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'photo.unfeatured',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
})
|
||||||
->icon('heroicon-o-star'),
|
->icon('heroicon-o-star'),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn (Photo $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkAction::make('feature')
|
Actions\BulkAction::make('feature')
|
||||||
->label(__('admin.photos.actions.feature_selected'))
|
->label(__('admin.photos.actions.feature_selected'))
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->action(fn ($records) => $records->each->update(['is_featured' => true])),
|
->action(function ($records): void {
|
||||||
|
$records->each->update(['is_featured' => true]);
|
||||||
|
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->record(
|
||||||
|
'photo.featured',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
Actions\BulkAction::make('unfeature')
|
Actions\BulkAction::make('unfeature')
|
||||||
->label(__('admin.photos.actions.unfeature_selected'))
|
->label(__('admin.photos.actions.unfeature_selected'))
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->action(fn ($records) => $records->each->update(['is_featured' => false])),
|
->action(function ($records): void {
|
||||||
Actions\DeleteBulkAction::make(),
|
$records->each->update(['is_featured' => false]);
|
||||||
|
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->record(
|
||||||
|
'photo.unfeatured',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PhotoResource\Pages;
|
namespace App\Filament\Resources\PhotoResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
use App\Filament\Resources\PhotoResource;
|
use App\Filament\Resources\PhotoResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditPhoto extends EditRecord
|
class EditPhoto extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PhotoResource::class;
|
protected static string $resource = PhotoResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PhotoboothSettings\Pages;
|
namespace App\Filament\Resources\PhotoboothSettings\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
|
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditPhotoboothSetting extends EditRecord
|
class EditPhotoboothSetting extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PhotoboothSettingResource::class;
|
protected static string $resource = PhotoboothSettingResource::class;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PhotoboothSettings\Tables;
|
namespace App\Filament\Resources\PhotoboothSettings\Tables;
|
||||||
|
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@@ -29,7 +30,13 @@ class PhotoboothSettingsTable
|
|||||||
->label(__('Aktualisiert')),
|
->label(__('Aktualisiert')),
|
||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Filament\Resources\PurchaseResource\Pages;
|
|||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Notifications\Customer\RefundReceipt;
|
use App\Notifications\Customer\RefundReceipt;
|
||||||
use App\Notifications\Ops\RefundProcessed;
|
use App\Notifications\Ops\RefundProcessed;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Services\Paddle\PaddleTransactionService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@@ -27,6 +28,7 @@ use Filament\Tables\Filters\Filter;
|
|||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
@@ -178,7 +180,13 @@ class PurchaseResource extends Resource
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make(),
|
EditAction::make()
|
||||||
|
->after(fn (array $data, PackagePurchase $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
Action::make('refund')
|
Action::make('refund')
|
||||||
->label('Refund')
|
->label('Refund')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
@@ -234,11 +242,29 @@ class PurchaseResource extends Resource
|
|||||||
if ($opsEmail) {
|
if ($opsEmail) {
|
||||||
Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage));
|
Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'purchase.refunded',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['refunded', 'metadata']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
DeleteBulkAction::make(),
|
DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No Purchases Found')
|
->emptyStateHeading('No Purchases Found')
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PurchaseResource\Pages;
|
namespace App\Filament\Resources\PurchaseResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
use App\Filament\Resources\PurchaseResource;
|
use App\Filament\Resources\PurchaseResource;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreatePurchase extends CreateRecord
|
class CreatePurchase extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PurchaseResource::class;
|
protected static string $resource = PurchaseResource::class;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PurchaseResource\Pages;
|
namespace App\Filament\Resources\PurchaseResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
use App\Filament\Resources\PurchaseResource;
|
use App\Filament\Resources\PurchaseResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditPurchase extends EditRecord
|
class EditPurchase extends AuditedEditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PurchaseResource::class;
|
protected static string $resource = PurchaseResource::class;
|
||||||
|
|
||||||
@@ -14,7 +15,12 @@ class EditPurchase extends EditRecord
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\PurchaseResource\Pages;
|
namespace App\Filament\Resources\PurchaseResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PurchaseResource;
|
use App\Filament\Resources\PurchaseResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
@@ -13,8 +14,19 @@ class ViewPurchase extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
Actions\DeleteAction::make(),
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
Actions\Action::make('refund')
|
Actions\Action::make('refund')
|
||||||
->label('Refund')
|
->label('Refund')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
@@ -24,6 +36,13 @@ class ViewPurchase extends ViewRecord
|
|||||||
->action(function ($record) {
|
->action(function ($record) {
|
||||||
$record->update(['refunded' => true]);
|
$record->update(['refunded' => true]);
|
||||||
// TODO: Call Paddle API for actual refund
|
// TODO: Call Paddle API for actual refund
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
|
'purchase.refunded',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['refunded']),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
75
app/Filament/Resources/RetentionOverrideResource.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource\Pages\CreateRetentionOverride;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource\Pages\EditRetentionOverride;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource\Pages\ListRetentionOverrides;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource\Schemas\RetentionOverrideForm;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource\Tables\RetentionOverrideTable;
|
||||||
|
use App\Models\RetentionOverride;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class RetentionOverrideResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = RetentionOverride::class;
|
||||||
|
|
||||||
|
protected static ?string $cluster = RareAdminCluster::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldExclamation;
|
||||||
|
|
||||||
|
protected static UnitEnum|string|null $navigationGroup = null;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 55;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return RetentionOverrideForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return RetentionOverrideTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.retention_overrides.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()->with(['tenant', 'event', 'createdBy', 'releasedBy']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListRetentionOverrides::route('/'),
|
||||||
|
'create' => CreateRetentionOverride::route('/create'),
|
||||||
|
'edit' => EditRetentionOverride::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete($record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDeleteAny(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||||
|
|
||||||
|
use App\Enums\RetentionOverrideScope;
|
||||||
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
class CreateRetentionOverride extends AuditedCreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = RetentionOverrideResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data['created_by_id'] = Filament::auth()->id();
|
||||||
|
$data['released_at'] = null;
|
||||||
|
$data['released_by_id'] = null;
|
||||||
|
|
||||||
|
if (($data['scope'] ?? null) !== RetentionOverrideScope::EVENT->value) {
|
||||||
|
$data['event_id'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource;
|
||||||
|
use App\Models\RetentionOverride;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
class EditRetentionOverride extends AuditedEditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = RetentionOverrideResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('release')
|
||||||
|
->label(__('admin.retention_overrides.actions.release'))
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn () => $this->record instanceof RetentionOverride && $this->record->released_at === null)
|
||||||
|
->action(function (): void {
|
||||||
|
if (! ($this->record instanceof RetentionOverride) || $this->record->released_at !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->record->forceFill([
|
||||||
|
'released_at' => now(),
|
||||||
|
'released_by_id' => Filament::auth()->id(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$this->record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
|
||||||
|
static::class
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\RetentionOverrideResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListRetentionOverrides extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = RetentionOverrideResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make()
|
||||||
|
->label(__('admin.retention_overrides.actions.request')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RetentionOverrideResource\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\RetentionOverrideScope;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\RetentionOverride;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Section;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class RetentionOverrideForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Section::make(__('admin.retention_overrides.sections.override'))
|
||||||
|
->schema([
|
||||||
|
Select::make('scope')
|
||||||
|
->label(__('admin.retention_overrides.fields.scope'))
|
||||||
|
->options([
|
||||||
|
RetentionOverrideScope::TENANT->value => __('admin.retention_overrides.scope.tenant'),
|
||||||
|
RetentionOverrideScope::EVENT->value => __('admin.retention_overrides.scope.event'),
|
||||||
|
])
|
||||||
|
->default(RetentionOverrideScope::TENANT->value)
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||||
|
Select::make('tenant_id')
|
||||||
|
->label(__('admin.retention_overrides.fields.tenant'))
|
||||||
|
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||||
|
Select::make('event_id')
|
||||||
|
->label(__('admin.retention_overrides.fields.event'))
|
||||||
|
->options(function (Get $get): array {
|
||||||
|
$tenantId = $get('tenant_id');
|
||||||
|
if (! $tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Event::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->orderByDesc('date')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function (Event $event): array {
|
||||||
|
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
|
||||||
|
|
||||||
|
return [$event->id => $name];
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->visible(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||||
|
->required(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||||
|
->dehydrated(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
|
||||||
|
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||||
|
TextInput::make('reason')
|
||||||
|
->label(__('admin.retention_overrides.fields.reason'))
|
||||||
|
->maxLength(200)
|
||||||
|
->required()
|
||||||
|
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||||
|
Textarea::make('note')
|
||||||
|
->label(__('admin.retention_overrides.fields.note'))
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(2000)
|
||||||
|
->columnSpanFull()
|
||||||
|
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make(__('admin.retention_overrides.sections.status'))
|
||||||
|
->schema([
|
||||||
|
Placeholder::make('created_by_id')
|
||||||
|
->label(__('admin.retention_overrides.fields.created_by'))
|
||||||
|
->content(fn (?RetentionOverride $record) => $record?->createdBy?->name ?? '—'),
|
||||||
|
Placeholder::make('created_at')
|
||||||
|
->label(__('admin.retention_overrides.fields.created_at'))
|
||||||
|
->content(fn (?RetentionOverride $record) => $record?->created_at?->diffForHumans() ?? '—'),
|
||||||
|
Placeholder::make('released_by_id')
|
||||||
|
->label(__('admin.retention_overrides.fields.released_by'))
|
||||||
|
->content(fn (?RetentionOverride $record) => $record?->releasedBy?->name ?? '—'),
|
||||||
|
Placeholder::make('released_at')
|
||||||
|
->label(__('admin.retention_overrides.fields.released_at'))
|
||||||
|
->content(fn (?RetentionOverride $record) => $record?->released_at?->diffForHumans() ?? '—'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\RetentionOverrideResource\Tables;
|
||||||
|
|
||||||
|
use App\Models\RetentionOverride;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class RetentionOverrideTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('id')
|
||||||
|
->label(__('admin.retention_overrides.fields.id'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('scope')
|
||||||
|
->label(__('admin.retention_overrides.fields.scope'))
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ? __('admin.retention_overrides.scope.'.$state) : '—'),
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label(__('admin.retention_overrides.fields.tenant'))
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('event.slug')
|
||||||
|
->label(__('admin.retention_overrides.fields.event'))
|
||||||
|
->toggleable()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('reason')
|
||||||
|
->label(__('admin.retention_overrides.fields.reason'))
|
||||||
|
->limit(40)
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label(__('admin.retention_overrides.fields.status'))
|
||||||
|
->state(fn (RetentionOverride $record) => $record->released_at ? 'released' : 'active')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (string $state) => __('admin.retention_overrides.status.'.$state))
|
||||||
|
->color(fn (string $state) => $state === 'released' ? 'gray' : 'success'),
|
||||||
|
TextColumn::make('createdBy.name')
|
||||||
|
->label(__('admin.retention_overrides.fields.created_by'))
|
||||||
|
->toggleable()
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label(__('admin.retention_overrides.fields.created_at'))
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('releasedBy.name')
|
||||||
|
->label(__('admin.retention_overrides.fields.released_by'))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('released_at')
|
||||||
|
->label(__('admin.retention_overrides.fields.released_at'))
|
||||||
|
->since()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->placeholder('—'),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('scope')
|
||||||
|
->label(__('admin.retention_overrides.fields.scope'))
|
||||||
|
->options([
|
||||||
|
'tenant' => __('admin.retention_overrides.scope.tenant'),
|
||||||
|
'event' => __('admin.retention_overrides.scope.event'),
|
||||||
|
]),
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label(__('admin.retention_overrides.fields.status'))
|
||||||
|
->options([
|
||||||
|
'active' => __('admin.retention_overrides.status.active'),
|
||||||
|
'released' => __('admin.retention_overrides.status.released'),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
return match ($data['value'] ?? null) {
|
||||||
|
'active' => $query->whereNull('released_at'),
|
||||||
|
'released' => $query->whereNotNull('released_at'),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('release')
|
||||||
|
->label(__('admin.retention_overrides.actions.release'))
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (RetentionOverride $record): bool => $record->released_at === null)
|
||||||
|
->action(function (RetentionOverride $record): void {
|
||||||
|
if ($record->released_at !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'released_at' => now(),
|
||||||
|
'released_by_id' => Filament::auth()->id(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
|
||||||
|
static::class
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
|
|||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\TaskResource\Pages;
|
use App\Filament\Resources\TaskResource\Pages;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\MarkdownEditor;
|
use Filament\Forms\Components\MarkdownEditor;
|
||||||
@@ -17,6 +18,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class TaskResource extends Resource
|
class TaskResource extends Resource
|
||||||
@@ -163,11 +165,33 @@ class TaskResource extends Resource
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
Actions\DeleteAction::make(),
|
->after(fn (array $data, Task $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->after(fn (Task $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make(),
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Filament\Resources\TaskResource\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\TaskResource;
|
use App\Filament\Resources\TaskResource;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Forms\Components\FileUpload;
|
use Filament\Forms\Components\FileUpload;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@@ -14,7 +15,9 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
class ImportTasks extends Page
|
class ImportTasks extends Page
|
||||||
{
|
{
|
||||||
protected static string $resource = TaskResource::class;
|
protected static string $resource = TaskResource::class;
|
||||||
|
|
||||||
protected string $view = 'filament.resources.task-resource.pages.import-tasks';
|
protected string $view = 'filament.resources.task-resource.pages.import-tasks';
|
||||||
|
|
||||||
protected ?string $heading = null;
|
protected ?string $heading = null;
|
||||||
|
|
||||||
public ?string $file = null;
|
public ?string $file = null;
|
||||||
@@ -35,8 +38,9 @@ class ImportTasks extends Page
|
|||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$path = $this->form->getState()['file'] ?? null;
|
$path = $this->form->getState()['file'] ?? null;
|
||||||
if (!$path || !Storage::disk('public')->exists($path)) {
|
if (! $path || ! Storage::disk('public')->exists($path)) {
|
||||||
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +62,14 @@ class ImportTasks extends Page
|
|||||||
private function importTasksCsv(string $file): array
|
private function importTasksCsv(string $file): array
|
||||||
{
|
{
|
||||||
$handle = fopen($file, 'r');
|
$handle = fopen($file, 'r');
|
||||||
if (!$handle) {
|
if (! $handle) {
|
||||||
return [0, 0];
|
return [0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$ok = 0;
|
$ok = 0;
|
||||||
$fail = 0;
|
$fail = 0;
|
||||||
$headers = fgetcsv($handle, 0, ',');
|
$headers = fgetcsv($handle, 0, ',');
|
||||||
if (!$headers) {
|
if (! $headers) {
|
||||||
return [0, 0];
|
return [0, 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ class ImportTasks extends Page
|
|||||||
$emotionId = DB::table('emotions')->where('name->en', $emotionNameEn)->value('id');
|
$emotionId = DB::table('emotions')->where('name->en', $emotionNameEn)->value('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$emotionId) {
|
if (! $emotionId) {
|
||||||
throw new \Exception('Emotion not found.');
|
throw new \Exception('Emotion not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +101,7 @@ class ImportTasks extends Page
|
|||||||
$eventTypeId = DB::table('event_types')->where('slug', $eventTypeSlug)->value('id');
|
$eventTypeId = DB::table('event_types')->where('slug', $eventTypeSlug)->value('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
Task::create([
|
$task = Task::create([
|
||||||
'emotion_id' => $emotionId,
|
'emotion_id' => $emotionId,
|
||||||
'event_type_id' => $eventTypeId,
|
'event_type_id' => $eventTypeId,
|
||||||
'title' => [
|
'title' => [
|
||||||
@@ -113,10 +117,17 @@ class ImportTasks extends Page
|
|||||||
'de' => $row[$map['example_text_de']] ?? null,
|
'de' => $row[$map['example_text_de']] ?? null,
|
||||||
'en' => $row[$map['example_text_en']] ?? null,
|
'en' => $row[$map['example_text_en']] ?? null,
|
||||||
],
|
],
|
||||||
'sort_order' => (int)($row[$map['sort_order']] ?? 0),
|
'sort_order' => (int) ($row[$map['sort_order']] ?? 0),
|
||||||
'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0,
|
'is_active' => (int) ($row[$map['is_active']] ?? 1) ? 1 : 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'created',
|
||||||
|
$task,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($task->getChanges()),
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
|
||||||
$ok++;
|
$ok++;
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -125,6 +136,7 @@ class ImportTasks extends Page
|
|||||||
}
|
}
|
||||||
|
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
|
||||||
return [$ok, $fail];
|
return [$ok, $fail];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\TaskResource\Pages;
|
namespace App\Filament\Resources\TaskResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TaskResource;
|
use App\Filament\Resources\TaskResource;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ManageRecords;
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
|
|
||||||
@@ -13,7 +14,13 @@ class ManageTasks extends ManageRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'created',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
Actions\Action::make('import')
|
Actions\Action::make('import')
|
||||||
->label(__('admin.common.import_csv'))
|
->label(__('admin.common.import_csv'))
|
||||||
->icon('heroicon-o-arrow-up-tray')
|
->icon('heroicon-o-arrow-up-tray')
|
||||||
|
|||||||
256
app/Filament/Resources/TenantAnnouncementResource.php
Normal file
256
app/Filament/Resources/TenantAnnouncementResource.php
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Enums\TenantAnnouncementAudience;
|
||||||
|
use App\Enums\TenantAnnouncementSegment;
|
||||||
|
use App\Enums\TenantAnnouncementStatus;
|
||||||
|
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
||||||
|
use App\Filament\Resources\TenantAnnouncementResource\Pages;
|
||||||
|
use App\Models\TenantAnnouncement;
|
||||||
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\CheckboxList;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class TenantAnnouncementResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = TenantAnnouncement::class;
|
||||||
|
|
||||||
|
protected static ?string $cluster = RareAdminCluster::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'title';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 70;
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
|
{
|
||||||
|
return __('admin.nav.platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
$statusOptions = collect(TenantAnnouncementStatus::cases())
|
||||||
|
->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$audienceOptions = collect(TenantAnnouncementAudience::cases())
|
||||||
|
->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$segmentOptions = collect(TenantAnnouncementSegment::cases())
|
||||||
|
->mapWithKeys(fn (TenantAnnouncementSegment $segment) => [$segment->value => $segment->label()])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Inhalt')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->required()
|
||||||
|
->maxLength(160),
|
||||||
|
Textarea::make('body')
|
||||||
|
->label('Text')
|
||||||
|
->rows(6)
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('cta_label')
|
||||||
|
->label('CTA-Label')
|
||||||
|
->maxLength(160),
|
||||||
|
TextInput::make('cta_url')
|
||||||
|
->label('CTA-Link')
|
||||||
|
->maxLength(255)
|
||||||
|
->url()
|
||||||
|
->nullable(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Zielgruppe')
|
||||||
|
->schema([
|
||||||
|
Select::make('audience')
|
||||||
|
->label('Zielgruppe')
|
||||||
|
->options($audienceOptions)
|
||||||
|
->default(TenantAnnouncementAudience::ALL->value)
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
Select::make('tenants')
|
||||||
|
->label('Mandanten')
|
||||||
|
->relationship('tenants', 'name')
|
||||||
|
->multiple()
|
||||||
|
->preload()
|
||||||
|
->searchable()
|
||||||
|
->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
|
||||||
|
->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
|
||||||
|
->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
|
||||||
|
->columnSpanFull(),
|
||||||
|
CheckboxList::make('segments')
|
||||||
|
->label('Segmente')
|
||||||
|
->options($segmentOptions)
|
||||||
|
->columns(2)
|
||||||
|
->default([])
|
||||||
|
->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
|
||||||
|
->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
|
||||||
|
->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
|
||||||
|
->columnSpanFull(),
|
||||||
|
Toggle::make('email_enabled')
|
||||||
|
->label('E-Mail versenden')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Zeitplan')
|
||||||
|
->schema([
|
||||||
|
Select::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->options($statusOptions)
|
||||||
|
->default(TenantAnnouncementStatus::DRAFT->value)
|
||||||
|
->live()
|
||||||
|
->required(),
|
||||||
|
DateTimePicker::make('starts_at')
|
||||||
|
->label('Startet am')
|
||||||
|
->seconds(false)
|
||||||
|
->nullable()
|
||||||
|
->required(fn (Get $get): bool => $get('status') === TenantAnnouncementStatus::SCHEDULED->value),
|
||||||
|
DateTimePicker::make('ends_at')
|
||||||
|
->label('Endet am')
|
||||||
|
->seconds(false)
|
||||||
|
->nullable(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
])
|
||||||
|
->columns(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
$statusOptions = collect(TenantAnnouncementStatus::cases())
|
||||||
|
->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$audienceOptions = collect(TenantAnnouncementAudience::cases())
|
||||||
|
->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->limit(50),
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(function ($state): string {
|
||||||
|
if ($state instanceof TenantAnnouncementStatus) {
|
||||||
|
return $state->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantAnnouncementStatus::tryFrom((string) $state)?->label() ?? (string) $state;
|
||||||
|
})
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('audience')
|
||||||
|
->label('Zielgruppe')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(function ($state): string {
|
||||||
|
if ($state instanceof TenantAnnouncementAudience) {
|
||||||
|
return $state->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantAnnouncementAudience::tryFrom((string) $state)?->label() ?? (string) $state;
|
||||||
|
})
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\IconColumn::make('email_enabled')
|
||||||
|
->label('E-Mail')
|
||||||
|
->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('starts_at')
|
||||||
|
->label('Start')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('ends_at')
|
||||||
|
->label('Ende')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('Aktualisiert')
|
||||||
|
->since()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->options($statusOptions),
|
||||||
|
Tables\Filters\SelectFilter::make('audience')
|
||||||
|
->label('Zielgruppe')
|
||||||
|
->options($audienceOptions),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\EditAction::make()
|
||||||
|
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'updated',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Actions\DeleteBulkAction::make()
|
||||||
|
->after(function (Collection $records): void {
|
||||||
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$logger->recordModelMutation(
|
||||||
|
'deleted',
|
||||||
|
$record,
|
||||||
|
source: static::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListTenantAnnouncements::route('/'),
|
||||||
|
'create' => Pages\CreateTenantAnnouncement::route('/create'),
|
||||||
|
'edit' => Pages\EditTenantAnnouncement::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
if ($userId = Auth::id()) {
|
||||||
|
$data['created_by'] = $userId;
|
||||||
|
$data['updated_by'] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
if ($userId = Auth::id()) {
|
||||||
|
$data['updated_by'] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\TenantAnnouncementResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
|
use App\Filament\Resources\TenantAnnouncementResource;
|
||||||
|
|
||||||
|
class CreateTenantAnnouncement extends AuditedCreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TenantAnnouncementResource::class;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user