Compare commits

...

3 Commits

Author SHA1 Message Date
Codex Agent
b316beb522 Allow partial event updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 15:12:03 +01:00
Codex Agent
6d3f4f36e8 Update tasks toggle copy 2026-01-16 15:06:48 +01:00
Codex Agent
9e4ea3dafb Add tasks toggle card 2026-01-16 14:58:24 +01:00
7 changed files with 197 additions and 5 deletions

View File

@@ -389,13 +389,21 @@ class EventController extends Controller
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$validated = $request->validated();
$nameProvided = array_key_exists('name', $validated);
$validated = array_merge([
'name' => $event->name,
'event_type_id' => $event->event_type_id,
'event_date' => $event->date?->toDateString(),
'status' => $event->status,
], $validated);
if (isset($validated['event_date'])) {
$validated['date'] = $validated['event_date'];
unset($validated['event_date']);
}
if ($validated['name'] !== $event->name) {
if ($nameProvided && $validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
}

View File

@@ -23,13 +23,14 @@ class EventStoreRequest extends FormRequest
public function rules(): array
{
$tenantId = request()->attributes->get('tenant_id');
$creating = $this->isMethod('post');
return [
'name' => ['required', 'string', 'max:255'],
'name' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'event_date' => ['required', 'date', 'after_or_equal:today'],
'event_date' => $creating ? ['required', 'date', 'after_or_equal:today'] : ['sometimes', 'date'],
'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'],
'event_type_id' => [$creating ? 'required' : 'sometimes', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'service_package_slug' => [
'nullable',

View File

@@ -2070,6 +2070,18 @@
"tasks": {
"disabledTitle": "Task-Modus ist für dieses Event aus",
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
"toggle": {
"title": "Aufgaben für dieses Event",
"description": "Gib Gästen optionale Foto-Ideen und kleine Challenges.",
"active": "AKTIV",
"inactive": "INAKTIV",
"onLabel": "Gäste sehen Aufgaben-Prompts",
"offLabel": "Gäste sehen nur Fotos",
"switchLabel": "Aufgaben aktiv",
"enabled": "Aufgaben aktiviert",
"disabled": "Aufgaben deaktiviert",
"permissionHint": "Du hast keine Berechtigung, Aufgaben zu ändern."
},
"title": "Tasks & Checklisten",
"quickNav": "Schnellzugriff",
"sections": {
@@ -2721,6 +2733,18 @@
"tasks": {
"disabledTitle": "Task-Modus ist für dieses Event aus",
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
"toggle": {
"title": "Aufgaben für dieses Event",
"description": "Gib Gästen optionale Foto-Ideen und kleine Challenges.",
"active": "AKTIV",
"inactive": "INAKTIV",
"onLabel": "Gäste sehen Aufgaben-Prompts",
"offLabel": "Gäste sehen nur Fotos",
"switchLabel": "Aufgaben aktiv",
"enabled": "Aufgaben aktiviert",
"disabled": "Aufgaben deaktiviert",
"permissionHint": "Du hast keine Berechtigung, Aufgaben zu ändern."
},
"title": "Tasks & Checklisten",
"quickNav": "Schnellzugriff",
"sections": {

View File

@@ -2074,6 +2074,18 @@
"tasks": {
"disabledTitle": "Task mode is off for this event",
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
"toggle": {
"title": "Tasks for this event",
"description": "Give guests optional prompts and photo ideas.",
"active": "ACTIVE",
"inactive": "INACTIVE",
"onLabel": "Guests see task prompts",
"offLabel": "Guest app shows photos only",
"switchLabel": "Tasks enabled",
"enabled": "Tasks activated",
"disabled": "Tasks disabled",
"permissionHint": "You do not have permission to change tasks."
},
"title": "Tasks & checklists",
"quickNav": "Quick jump",
"sections": {
@@ -2725,6 +2737,18 @@
"tasks": {
"disabledTitle": "Task mode is off for this event",
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
"toggle": {
"title": "Tasks for this event",
"description": "Give guests optional prompts and photo ideas.",
"active": "ACTIVE",
"inactive": "INACTIVE",
"onLabel": "Guests see task prompts",
"offLabel": "Guest app shows photos only",
"switchLabel": "Tasks enabled",
"enabled": "Tasks activated",
"disabled": "Tasks disabled",
"permissionHint": "You do not have permission to change tasks."
},
"title": "Tasks & checklists",
"quickNav": "Quick jump",
"sections": {

View File

@@ -12,13 +12,15 @@ import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { Switch } from '@tamagui/switch';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
getEvent,
getEvents,
getEventTasks,
updateEvent,
updateTask,
TenantTask,
TenantEvent,
@@ -48,6 +50,19 @@ import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
import { withAlpha } from './components/colors';
import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme';
import { resolveEngagementMode } from '../lib/events';
import { useAuth } from '../auth/context';
function allowPermission(permissions: string[], permission: string): boolean {
if (permissions.includes('*') || permissions.includes(permission)) {
return true;
}
if (permission.includes(':')) {
const [prefix] = permission.split(':');
return permissions.includes(`${prefix}:*`);
}
return false;
}
function TaskSummaryCard({ summary }: { summary: ReturnType<typeof buildTaskSummary> }) {
const { t } = useTranslation('management');
@@ -238,7 +253,9 @@ export default function MobileEventTasksPage() {
const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { user } = useAuth();
const { textStrong, muted, subtle, border, primary, danger, surface, surfaceMuted, dangerBg, dangerText, overlay } = useAdminTheme();
const isMember = user?.role === 'member';
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
@@ -271,6 +288,8 @@ export default function MobileEventTasksPage() {
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const [quickNavSelection, setQuickNavSelection] = React.useState<TaskSectionKey | ''>('');
const [eventRecord, setEventRecord] = React.useState<TenantEvent | null>(null);
const [tasksToggleBusy, setTasksToggleBusy] = React.useState(false);
const text = textStrong;
const assignedRef = React.useRef<HTMLDivElement>(null);
const libraryRef = React.useRef<HTMLDivElement>(null);
@@ -281,6 +300,13 @@ export default function MobileEventTasksPage() {
collections: collections.length,
emotions: emotions.length,
});
const permissionSource = eventRecord ?? activeEvent;
const memberPermissions = Array.isArray(permissionSource?.member_permissions) ? permissionSource?.member_permissions ?? [] : [];
const canManageTasks = React.useMemo(
() => (isMember ? allowPermission(memberPermissions, 'tasks:manage') : true),
[isMember, memberPermissions]
);
const tasksEnabled = resolveEngagementMode(permissionSource ?? null) !== 'photo_only';
const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
@@ -339,6 +365,7 @@ export default function MobileEventTasksPage() {
try {
const event = await getEvent(slug);
setEventId(event.id);
setEventRecord(event);
const [result, libraryTasks] = await Promise.all([
getEventTasks(event.id, 1),
getTasks({ per_page: 200 }),
@@ -574,6 +601,30 @@ export default function MobileEventTasksPage() {
}
}
async function handleTasksToggle(nextEnabled: boolean) {
if (!slug || tasksToggleBusy || !canManageTasks) return;
setTasksToggleBusy(true);
try {
const updated = await updateEvent(slug, {
settings: {
engagement_mode: nextEnabled ? 'tasks' : 'photo_only',
},
});
setEventRecord(updated);
toast.success(
nextEnabled
? t('events.tasks.toggle.enabled', 'Tasks activated')
: t('events.tasks.toggle.disabled', 'Tasks disabled')
);
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('events.errors.toggleFailed', 'Status could not be updated.'));
}
} finally {
setTasksToggleBusy(false);
}
}
return (
<MobileShell
activeTab="tasks"
@@ -601,6 +652,53 @@ export default function MobileEventTasksPage() {
</MobileCard>
) : null}
{!loading ? (
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('events.tasks.toggle.title', 'Tasks for this event')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'events.tasks.toggle.description',
'Give guests optional prompts and photo ideas.'
)}
</Text>
</YStack>
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<PillBadge tone={tasksEnabled ? 'success' : 'warning'}>
{tasksEnabled
? t('events.tasks.toggle.active', 'ACTIVE')
: t('events.tasks.toggle.inactive', 'INACTIVE')}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{tasksEnabled
? t('events.tasks.toggle.onLabel', 'Guests see task prompts')
: t('events.tasks.toggle.offLabel', 'Guest app shows photos only')}
</Text>
</XStack>
<XStack alignItems="center" justifyContent="space-between" marginTop="$2">
<Text fontSize="$xs" color={text} fontWeight="600">
{t('events.tasks.toggle.switchLabel', 'Tasks enabled')}
</Text>
<Switch
size="$4"
checked={tasksEnabled}
onCheckedChange={handleTasksToggle}
aria-label={t('events.tasks.toggle.switchLabel', 'Tasks enabled')}
disabled={!canManageTasks || tasksToggleBusy}
>
<Switch.Thumb />
</Switch>
</XStack>
{isMember && !canManageTasks ? (
<Text fontSize="$xs" color={muted}>
{t('events.tasks.toggle.permissionHint', 'You do not have permission to change tasks.')}
</Text>
) : null}
</MobileCard>
) : null}
{!loading ? (
<TaskSummaryCard summary={summary} />
) : null}

View File

@@ -57,6 +57,10 @@ vi.mock('../../context/EventContext', () => ({
}),
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ user: { role: 'tenant_admin' } }),
}));
vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEvents: vi.fn().mockResolvedValue([fixtures.event]),
@@ -65,6 +69,7 @@ vi.mock('../../api', () => ({
getTaskCollections: vi.fn().mockResolvedValue({ data: fixtures.collections }),
getEmotions: vi.fn().mockResolvedValue(fixtures.emotions),
assignTasksToEvent: vi.fn(),
updateEvent: vi.fn().mockResolvedValue(fixtures.event),
updateTask: vi.fn(),
importTaskCollection: vi.fn(),
createTask: vi.fn(),
@@ -110,6 +115,13 @@ vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{ Thumb: () => <div /> },
),
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div>
@@ -162,6 +174,7 @@ vi.mock('../components/MobileShell', () => ({
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
@@ -215,6 +228,7 @@ describe('MobileEventTasksPage', () => {
it('renders the task overview summary and quick jump chips', async () => {
render(<MobileEventTasksPage />);
expect(await screen.findByText('Tasks for this event')).toBeInTheDocument();
expect(await screen.findByText('Task overview')).toBeInTheDocument();
expect(screen.getByText('Tasks total')).toBeInTheDocument();
expect(screen.getByText('Quick jump')).toBeInTheDocument();

View File

@@ -199,6 +199,29 @@ class EventControllerTest extends TenantTestCase
->assertJsonPath('error.code', 'event_limit_exceeded');
}
public function test_update_event_settings_without_required_fields_succeeds(): void
{
$tenant = $this->tenant;
$eventType = EventType::factory()->create();
$event = Event::factory()->create([
'tenant_id' => $tenant->id,
'event_type_id' => $eventType->id,
'date' => Carbon::now()->subDays(2),
'name' => ['de' => 'Test Event', 'en' => 'Test Event'],
'settings' => [],
]);
$response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [
'settings' => [
'engagement_mode' => 'photo_only',
],
]);
$response->assertOk();
$event->refresh();
$this->assertSame('photo_only', data_get($event->settings, 'engagement_mode'));
}
public function test_create_event_rejects_unavailable_service_tier_for_partner_kontingent(): void
{
$tenant = $this->tenant;