fix: resolve 4 QA bugs found in Docker production build
- fix(member-management): Zod v4 .partial() on refined schema crash Separated CreateMemberBaseSchema from superRefine so .partial() works for UpdateMemberSchema. Fixes members-cms page crash. - fix(course-management): snake_case→camelCase stats normalization getQuickStats RPC returns snake_case keys but templates expect camelCase. Added normalization layer so stats cards display values. - fix(blog): add missing cover images for 5 German blog posts Posts referenced /images/posts/*.webp that didn't exist. - fix(docker): remove non-existent catch_entries table from bootstrap dev-bootstrap.sh granted permissions on catch_entries which has no migration. Removed the stale reference. - docs: add qa-checklist.md with full test report
This commit is contained in:
BIN
apps/web/public/images/posts/digitale-verwaltung.webp
Normal file
BIN
apps/web/public/images/posts/digitale-verwaltung.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/web/public/images/posts/dsgvo-vereine.webp
Normal file
BIN
apps/web/public/images/posts/dsgvo-vereine.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
apps/web/public/images/posts/mitgliederverwaltung.webp
Normal file
BIN
apps/web/public/images/posts/mitgliederverwaltung.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/web/public/images/posts/sepa-lastschrift.webp
Normal file
BIN
apps/web/public/images/posts/sepa-lastschrift.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/web/public/images/posts/vereinswebsite.webp
Normal file
BIN
apps/web/public/images/posts/vereinswebsite.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -59,7 +59,6 @@ $PSQL -c "
|
|||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fish_stocking TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_leases TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_books TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.catch_entries TO authenticated;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_permits TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.fishing_competitions TO authenticated;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_clubs TO authenticated;
|
||||||
|
|||||||
@@ -13,21 +13,31 @@ export function createCourseStatisticsService(
|
|||||||
{ p_account_id: accountId },
|
{ p_account_id: accountId },
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
// RPC returns a single row as an array
|
// RPC returns a single row as an array with snake_case keys
|
||||||
const stats = Array.isArray(data) ? data[0] : data;
|
const raw = Array.isArray(data) ? data[0] : data;
|
||||||
return (
|
const s = raw ?? {
|
||||||
stats ?? {
|
total_courses: 0,
|
||||||
total_courses: 0,
|
open_courses: 0,
|
||||||
open_courses: 0,
|
running_courses: 0,
|
||||||
running_courses: 0,
|
completed_courses: 0,
|
||||||
completed_courses: 0,
|
cancelled_courses: 0,
|
||||||
cancelled_courses: 0,
|
total_participants: 0,
|
||||||
total_participants: 0,
|
total_waitlisted: 0,
|
||||||
total_waitlisted: 0,
|
avg_occupancy_rate: 0,
|
||||||
avg_occupancy_rate: 0,
|
total_revenue: 0,
|
||||||
total_revenue: 0,
|
};
|
||||||
}
|
// Normalise to camelCase for consumers
|
||||||
);
|
return {
|
||||||
|
totalCourses: s.total_courses ?? s.totalCourses ?? 0,
|
||||||
|
openCourses: s.open_courses ?? s.openCourses ?? 0,
|
||||||
|
runningCourses: s.running_courses ?? s.runningCourses ?? 0,
|
||||||
|
completedCourses: s.completed_courses ?? s.completedCourses ?? 0,
|
||||||
|
cancelledCourses: s.cancelled_courses ?? s.cancelledCourses ?? 0,
|
||||||
|
totalParticipants: s.total_participants ?? s.totalParticipants ?? 0,
|
||||||
|
totalWaitlisted: s.total_waitlisted ?? s.totalWaitlisted ?? 0,
|
||||||
|
avgOccupancyRate: s.avg_occupancy_rate ?? s.avgOccupancyRate ?? 0,
|
||||||
|
totalRevenue: s.total_revenue ?? s.totalRevenue ?? 0,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAttendanceSummary(courseId: string) {
|
async getAttendanceSummary(courseId: string) {
|
||||||
|
|||||||
@@ -56,102 +56,112 @@ const dateNotFutureSchema = (fieldName: string) =>
|
|||||||
|
|
||||||
// --- Main schemas ---
|
// --- Main schemas ---
|
||||||
|
|
||||||
export const CreateMemberSchema = z
|
// Base object without refinements — used for .partial() in UpdateMemberSchema
|
||||||
.object({
|
const CreateMemberBaseSchema = z.object({
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
memberNumber: z.string().optional(),
|
memberNumber: z.string().optional(),
|
||||||
firstName: z.string().min(1).max(128),
|
firstName: z.string().min(1).max(128),
|
||||||
lastName: z.string().min(1).max(128),
|
lastName: z.string().min(1).max(128),
|
||||||
dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
|
dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
|
||||||
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
||||||
title: z.string().max(32).optional(),
|
title: z.string().max(32).optional(),
|
||||||
email: z.string().email().optional().or(z.literal('')),
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
phone: z.string().max(32).optional(),
|
phone: z.string().max(32).optional(),
|
||||||
mobile: z.string().max(32).optional(),
|
mobile: z.string().max(32).optional(),
|
||||||
street: z.string().max(256).optional(),
|
street: z.string().max(256).optional(),
|
||||||
houseNumber: z.string().max(16).optional(),
|
houseNumber: z.string().max(16).optional(),
|
||||||
postalCode: z.string().max(10).optional(),
|
postalCode: z.string().max(10).optional(),
|
||||||
city: z.string().max(128).optional(),
|
city: z.string().max(128).optional(),
|
||||||
country: z.string().max(2).default('DE'),
|
country: z.string().max(2).default('DE'),
|
||||||
status: MembershipStatusEnum.default('active'),
|
status: MembershipStatusEnum.default('active'),
|
||||||
entryDate: z
|
entryDate: z
|
||||||
.string()
|
.string()
|
||||||
.default(() => new Date().toISOString().split('T')[0]!),
|
.default(() => new Date().toISOString().split('T')[0]!),
|
||||||
duesCategoryId: z.string().uuid().optional(),
|
duesCategoryId: z.string().uuid().optional(),
|
||||||
iban: ibanSchema,
|
iban: ibanSchema,
|
||||||
bic: z.string().max(11).optional(),
|
bic: z.string().max(11).optional(),
|
||||||
accountHolder: z.string().max(128).optional(),
|
accountHolder: z.string().max(128).optional(),
|
||||||
gdprConsent: z.boolean().default(false),
|
gdprConsent: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
salutation: z.string().optional(),
|
salutation: z.string().optional(),
|
||||||
street2: z.string().optional(),
|
street2: z.string().optional(),
|
||||||
phone2: z.string().optional(),
|
phone2: z.string().optional(),
|
||||||
fax: z.string().optional(),
|
fax: z.string().optional(),
|
||||||
birthplace: z.string().optional(),
|
birthplace: z.string().optional(),
|
||||||
birthCountry: z.string().default('DE'),
|
birthCountry: z.string().default('DE'),
|
||||||
isHonorary: z.boolean().default(false),
|
isHonorary: z.boolean().default(false),
|
||||||
isFoundingMember: z.boolean().default(false),
|
isFoundingMember: z.boolean().default(false),
|
||||||
isYouth: z.boolean().default(false),
|
isYouth: z.boolean().default(false),
|
||||||
isRetiree: z.boolean().default(false),
|
isRetiree: z.boolean().default(false),
|
||||||
isProbationary: z.boolean().default(false),
|
isProbationary: z.boolean().default(false),
|
||||||
isTransferred: z.boolean().default(false),
|
isTransferred: z.boolean().default(false),
|
||||||
exitDate: z.string().optional(),
|
exitDate: z.string().optional(),
|
||||||
exitReason: z.string().optional(),
|
exitReason: z.string().optional(),
|
||||||
guardianName: z.string().optional(),
|
guardianName: z.string().optional(),
|
||||||
guardianPhone: z.string().optional(),
|
guardianPhone: z.string().optional(),
|
||||||
guardianEmail: z.string().optional(),
|
guardianEmail: z.string().optional(),
|
||||||
duesYear: z.number().int().optional(),
|
duesYear: z.number().int().optional(),
|
||||||
duesPaid: z.boolean().default(false),
|
duesPaid: z.boolean().default(false),
|
||||||
additionalFees: z.number().default(0),
|
additionalFees: z.number().default(0),
|
||||||
exemptionType: z.string().optional(),
|
exemptionType: z.string().optional(),
|
||||||
exemptionReason: z.string().optional(),
|
exemptionReason: z.string().optional(),
|
||||||
exemptionAmount: z.number().optional(),
|
exemptionAmount: z.number().optional(),
|
||||||
gdprNewsletter: z.boolean().default(false),
|
gdprNewsletter: z.boolean().default(false),
|
||||||
gdprInternet: z.boolean().default(false),
|
gdprInternet: z.boolean().default(false),
|
||||||
gdprPrint: z.boolean().default(false),
|
gdprPrint: z.boolean().default(false),
|
||||||
gdprBirthdayInfo: z.boolean().default(false),
|
gdprBirthdayInfo: z.boolean().default(false),
|
||||||
sepaMandateReference: z.string().optional(),
|
sepaMandateReference: z.string().optional(),
|
||||||
})
|
});
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
// Cross-field: exit_date must be after entry_date
|
|
||||||
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
|
|
||||||
path: ['exitDate'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cross-field: entry_date must be after date_of_birth
|
/** Cross-field refinement shared by create/update */
|
||||||
if (
|
function memberCrossFieldRefinement(
|
||||||
data.dateOfBirth &&
|
data: Record<string, unknown>,
|
||||||
data.entryDate &&
|
ctx: z.RefinementCtx,
|
||||||
data.entryDate < data.dateOfBirth
|
) {
|
||||||
) {
|
// Cross-field: exit_date must be after entry_date
|
||||||
ctx.addIssue({
|
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
|
||||||
code: z.ZodIssueCode.custom,
|
ctx.addIssue({
|
||||||
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['entryDate'],
|
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
|
||||||
});
|
path: ['exitDate'],
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cross-field: youth members should have guardian info
|
// Cross-field: entry_date must be after date_of_birth
|
||||||
if (data.isYouth && !data.guardianName) {
|
if (
|
||||||
ctx.addIssue({
|
data.dateOfBirth &&
|
||||||
code: z.ZodIssueCode.custom,
|
data.entryDate &&
|
||||||
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
|
data.entryDate < data.dateOfBirth
|
||||||
path: ['guardianName'],
|
) {
|
||||||
});
|
ctx.addIssue({
|
||||||
}
|
code: z.ZodIssueCode.custom,
|
||||||
});
|
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
|
||||||
|
path: ['entryDate'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-field: youth members should have guardian info
|
||||||
|
if (data.isYouth && !data.guardianName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
|
||||||
|
path: ['guardianName'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateMemberSchema =
|
||||||
|
CreateMemberBaseSchema.superRefine(memberCrossFieldRefinement);
|
||||||
|
|
||||||
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
||||||
|
|
||||||
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
|
export const UpdateMemberSchema = CreateMemberBaseSchema.partial()
|
||||||
memberId: z.string().uuid(),
|
.extend({
|
||||||
isArchived: z.boolean().optional(),
|
memberId: z.string().uuid(),
|
||||||
version: z.number().int().optional(),
|
isArchived: z.boolean().optional(),
|
||||||
});
|
version: z.number().int().optional(),
|
||||||
|
})
|
||||||
|
.superRefine(memberCrossFieldRefinement);
|
||||||
|
|
||||||
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
||||||
|
|
||||||
|
|||||||
135
qa-checklist.md
Normal file
135
qa-checklist.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# QA Checklist — MYeasyCMS v2
|
||||||
|
|
||||||
|
**Date**: 2026-04-03
|
||||||
|
**Test method**: Docker production build (`docker compose -f docker-compose.local.yml up --build`)
|
||||||
|
**Test user**: `test@makerkit.dev` / `testingpassword` (super-admin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUGS FOUND & FIXED
|
||||||
|
|
||||||
|
### BUG #1 — Members CMS crashes with Zod v4 error (CRITICAL) ✅ FIXED
|
||||||
|
- **Route**: `/home/[account]/members-cms`
|
||||||
|
- **Error**: `Error: .partial() cannot be used on object schemas containing refinements`
|
||||||
|
- **Root cause**: `CreateMemberSchema` uses `.superRefine()` for cross-field validation, then `UpdateMemberSchema = CreateMemberSchema.partial()` fails in Zod v4.
|
||||||
|
- **Fix**: Separated base object schema from refinements. `CreateMemberBaseSchema` (plain object) is used for `.partial()`, and the refinement function `memberCrossFieldRefinement` is applied separately to both Create and Update schemas.
|
||||||
|
- **File**: `packages/features/member-management/src/schema/member.schema.ts`
|
||||||
|
- **Status**: ✅ Verified in Docker rebuild — members page loads with 30 members displayed
|
||||||
|
|
||||||
|
### BUG #2 — Course stats cards show empty values (MEDIUM) ✅ FIXED
|
||||||
|
- **Route**: `/home/[account]/courses`
|
||||||
|
- **Symptom**: Stats cards showed labels (Gesamt, Aktiv, Abgeschlossen, Teilnehmer) but no numbers
|
||||||
|
- **Root cause**: The `getQuickStats` RPC returns snake_case keys (`total_courses`, `open_courses`) but the template uses camelCase (`stats.totalCourses`, `stats.openCourses`), resulting in `undefined`
|
||||||
|
- **Fix**: Added camelCase normalization in `createCourseStatisticsService.getQuickStats()`
|
||||||
|
- **File**: `packages/features/course-management/src/server/services/course-statistics.service.ts`
|
||||||
|
- **Status**: ✅ Verified in Docker rebuild — all 4 stats cards show "0" correctly
|
||||||
|
|
||||||
|
### BUG #3 — Blog post images missing (MEDIUM) ✅ FIXED
|
||||||
|
- **Route**: `/blog`
|
||||||
|
- **Symptom**: All blog post cards showed broken image alt text instead of cover images
|
||||||
|
- **Root cause**: Blog posts reference images like `/images/posts/mitgliederverwaltung.webp` but only 3 default Makerkit images existed in `public/images/posts/`
|
||||||
|
- **Fix**: Created placeholder images for all 5 German blog posts
|
||||||
|
- **Files added**: `apps/web/public/images/posts/{mitgliederverwaltung,dsgvo-vereine,sepa-lastschrift,digitale-verwaltung,vereinswebsite}.webp`
|
||||||
|
- **Status**: ✅ Verified in Docker rebuild — all blog images load
|
||||||
|
|
||||||
|
### BUG #4 — Dev bootstrap references non-existent table (LOW) ✅ FIXED
|
||||||
|
- **Error**: `ERROR: relation "public.catch_entries" does not exist` during DB seeding
|
||||||
|
- **Root cause**: `docker/db/dev-bootstrap.sh` grants permissions on `catch_entries` table, but no migration creates it. The actual table is `catch_books`.
|
||||||
|
- **Fix**: Removed the `catch_entries` GRANT line from dev-bootstrap.sh
|
||||||
|
- **File**: `docker/db/dev-bootstrap.sh`
|
||||||
|
- **Status**: ✅ Fixed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KNOWN ISSUES (NOT FIXED — LOW PRIORITY)
|
||||||
|
|
||||||
|
### ISSUE #5 — Admin panel not translated to German
|
||||||
|
- **Route**: `/admin`
|
||||||
|
- **Symptom**: Admin dashboard shows English labels ("Users", "Team Accounts", "Paying Customers") while rest of app is in German
|
||||||
|
- **Impact**: Low — admin panel is internal-facing only
|
||||||
|
- **Fix needed**: Add German translations for admin section in i18n
|
||||||
|
|
||||||
|
### ISSUE #6 — Hydration mismatch warning (dev-only)
|
||||||
|
- **Source**: `next-runtime-env` `PublicEnvScript` component
|
||||||
|
- **Impact**: None — this is a known Next.js 16 framework-level issue with script serialization. The `suppressHydrationWarning` is already set on `<html>`.
|
||||||
|
- **Fix needed**: None — wait for upstream fix in `next-runtime-env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PAGES TESTED — ALL PASSING ✅
|
||||||
|
|
||||||
|
### Marketing Pages (Public)
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/` (Homepage) | ✅ Pass | Hero, stats, features, testimonials, pricing, CTA all render |
|
||||||
|
| `/blog` | ✅ Pass | Blog cards with images, dates, descriptions |
|
||||||
|
| `/docs` | ✅ Pass | Documentation sidebar navigation, category cards |
|
||||||
|
| `/pricing` | ✅ Pass | 4 pricing tiers, monthly/yearly toggle, feature lists |
|
||||||
|
| `/faq` | ✅ Pass | Accordion FAQ items with expand/collapse |
|
||||||
|
| `/contact` | ✅ Pass | Contact form with name, email, message fields |
|
||||||
|
|
||||||
|
### Auth Pages
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/auth/sign-in` | ✅ Pass | Email/password login, social auth buttons (Google, Apple, Azure, GitHub) |
|
||||||
|
| `/auth/sign-up` | ✅ Pass (navigable) | Registration form |
|
||||||
|
|
||||||
|
### Personal Dashboard
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/home` | ✅ Pass | Personal home with sidebar, account selector |
|
||||||
|
| `/home/settings` | ✅ Pass (navigable) | Profile settings |
|
||||||
|
|
||||||
|
### Team Dashboard (Makerkit workspace)
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/home/makerkit` | ✅ Pass | Dashboard with member stats, course stats, invoices, newsletters, quick actions |
|
||||||
|
| `/home/makerkit/members-cms` | ✅ Pass | Member list with search, filters, status badges, pagination (30 members) |
|
||||||
|
| `/home/makerkit/courses` | ✅ Pass | Course list with stats cards, search, status filter |
|
||||||
|
| `/home/makerkit/events` | ✅ Pass | Events list with stats (Veranstaltungen, Orte, Kapazität) |
|
||||||
|
| `/home/makerkit/finance` | ✅ Pass | SEPA + Invoices overview with stats |
|
||||||
|
| `/home/makerkit/newsletter` | ✅ Pass | Newsletter list with stats |
|
||||||
|
| `/home/makerkit/bookings` | ✅ Pass | Booking management with rooms/active/revenue stats |
|
||||||
|
| `/home/makerkit/documents` | ✅ Pass | Document generator cards (Mitgliedsausweis, Rechnung, Etiketten, etc.) |
|
||||||
|
| `/home/makerkit/site-builder` | ✅ Pass | Page builder with settings, posts, status |
|
||||||
|
| `/home/makerkit/meetings` | ✅ Pass | Meeting protocols with stats (Protokolle, Aufgaben) |
|
||||||
|
| `/home/makerkit/fischerei` | ✅ Pass | Fishing module with 8 tabs, 6 stat cards |
|
||||||
|
| `/home/makerkit/verband` | ✅ Pass | Federation management with 9 tabs, 6 stat cards |
|
||||||
|
| `/home/makerkit/settings` | ✅ Pass | Team logo, team name, danger zone |
|
||||||
|
|
||||||
|
### Admin Panel
|
||||||
|
| Route | Status | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `/admin` | ✅ Pass | Super admin dashboard with Users/Team/Paying/Trials stats |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INTERACTIVE ELEMENTS VERIFIED
|
||||||
|
|
||||||
|
- ✅ Navigation links (Blog, Docs, Pricing, FAQ, Contact)
|
||||||
|
- ✅ Auth flow (sign-in with email/password)
|
||||||
|
- ✅ Account/workspace selector dropdown
|
||||||
|
- ✅ Sidebar navigation (collapsed/expanded)
|
||||||
|
- ✅ Stats cards (all modules)
|
||||||
|
- ✅ Member list table with avatars, status badges, tags
|
||||||
|
- ✅ Search inputs (members, courses, bookings, newsletter)
|
||||||
|
- ✅ Status filter dropdowns
|
||||||
|
- ✅ Tab navigation (Fischerei, Meetings, Verband)
|
||||||
|
- ✅ Quick action buttons (New Member, New Course, etc.)
|
||||||
|
- ✅ FAQ accordion expand/collapse
|
||||||
|
- ✅ Theme toggle button
|
||||||
|
- ✅ Sign In / Sign Up buttons
|
||||||
|
- ✅ Breadcrumb navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FILES MODIFIED
|
||||||
|
|
||||||
|
1. `packages/features/member-management/src/schema/member.schema.ts` — Zod v4 partial() fix
|
||||||
|
2. `packages/features/course-management/src/server/services/course-statistics.service.ts` — snake_case→camelCase normalization
|
||||||
|
3. `apps/web/public/images/posts/mitgliederverwaltung.webp` — Added blog image
|
||||||
|
4. `apps/web/public/images/posts/dsgvo-vereine.webp` — Added blog image
|
||||||
|
5. `apps/web/public/images/posts/sepa-lastschrift.webp` — Added blog image
|
||||||
|
6. `apps/web/public/images/posts/digitale-verwaltung.webp` — Added blog image
|
||||||
|
7. `apps/web/public/images/posts/vereinswebsite.webp` — Added blog image
|
||||||
|
8. `docker/db/dev-bootstrap.sh` — Removed non-existent catch_entries table reference
|
||||||
Reference in New Issue
Block a user