mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
feat: enhance FormSubmissions with workflow and tracking
- Add status workflow: new → read → in-progress → waiting → completed → archived - Add priority levels (high, normal, low) - Add assignedTo field for team member assignment - Add internal notes array with author and timestamp - Add response tracking (responded, method, summary) - Add tags for categorization - Auto-mark as read on first view - Auto-set note author and timestamp - Improved admin view with better columns - Update documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8868a5be30
commit
17eb46a787
6 changed files with 476 additions and 2 deletions
|
|
@ -411,7 +411,7 @@ SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10;
|
|||
| NewsletterSubscribers | newsletter-subscribers | Newsletter mit Double Opt-In |
|
||||
| SocialLinks | social-links | Social Media Links |
|
||||
| Forms | forms | Formular-Builder |
|
||||
| FormSubmissions | form-submissions | Formular-Einsendungen |
|
||||
| FormSubmissions | form-submissions | Formular-Einsendungen mit Status-Workflow |
|
||||
| EmailLogs | email-logs | E-Mail-Protokollierung |
|
||||
| AuditLogs | audit-logs | Security Audit Trail |
|
||||
| CookieConfigurations | cookie-configurations | Cookie-Banner Konfiguration |
|
||||
|
|
|
|||
215
src/collections/FormSubmissionsOverrides.ts
Normal file
215
src/collections/FormSubmissionsOverrides.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// src/collections/FormSubmissionsOverrides.ts
|
||||
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
/**
|
||||
* FormSubmissions Overrides
|
||||
*
|
||||
* Erweitert die vom formBuilderPlugin erstellte FormSubmissions Collection um:
|
||||
* - Status-Workflow (neu → in Bearbeitung → erledigt)
|
||||
* - Interne Notizen
|
||||
* - Zuständigkeits-Zuweisung
|
||||
* - Verbesserte Admin-Ansicht
|
||||
*/
|
||||
export const formSubmissionOverrides: Partial<CollectionConfig> = {
|
||||
admin: {
|
||||
useAsTitle: 'id',
|
||||
group: 'Formulare',
|
||||
defaultColumns: ['id', 'form', 'status', 'assignedTo', 'createdAt'],
|
||||
description: 'Eingegangene Formular-Einsendungen',
|
||||
listSearchableFields: ['id', 'status'],
|
||||
},
|
||||
labels: {
|
||||
singular: 'Formular-Einsendung',
|
||||
plural: 'Formular-Einsendungen',
|
||||
},
|
||||
fields: [
|
||||
// Status-Workflow
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'new',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: '🆕 Neu', value: 'new' },
|
||||
{ label: '👀 Gelesen', value: 'read' },
|
||||
{ label: '🔄 In Bearbeitung', value: 'in-progress' },
|
||||
{ label: '⏳ Warten auf Rückmeldung', value: 'waiting' },
|
||||
{ label: '✅ Erledigt', value: 'completed' },
|
||||
{ label: '🗑️ Spam/Archiviert', value: 'archived' },
|
||||
],
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
// Priorität
|
||||
{
|
||||
name: 'priority',
|
||||
type: 'select',
|
||||
defaultValue: 'normal',
|
||||
label: 'Priorität',
|
||||
options: [
|
||||
{ label: '🔴 Hoch', value: 'high' },
|
||||
{ label: '🟡 Normal', value: 'normal' },
|
||||
{ label: '🟢 Niedrig', value: 'low' },
|
||||
],
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
// Zuständigkeit
|
||||
{
|
||||
name: 'assignedTo',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
label: 'Zuständig',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Wer bearbeitet diese Anfrage?',
|
||||
},
|
||||
},
|
||||
// Interne Notizen
|
||||
{
|
||||
name: 'internalNotes',
|
||||
type: 'array',
|
||||
label: 'Interne Notizen',
|
||||
admin: {
|
||||
description: 'Interne Kommunikation zur Anfrage (nicht für Kunden sichtbar)',
|
||||
initCollapsed: false,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'note',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: 'Notiz',
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
label: 'Autor',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Wird automatisch gesetzt',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
label: 'Erstellt am',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
displayFormat: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Antwort-Tracking
|
||||
{
|
||||
name: 'responseTracking',
|
||||
type: 'group',
|
||||
label: 'Antwort-Tracking',
|
||||
admin: {
|
||||
description: 'Tracking der Kommunikation mit dem Absender',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'responded',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
label: 'Antwort gesendet',
|
||||
},
|
||||
{
|
||||
name: 'respondedAt',
|
||||
type: 'date',
|
||||
label: 'Antwort gesendet am',
|
||||
admin: {
|
||||
condition: (data, siblingData) => siblingData?.responded,
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
displayFormat: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'respondedBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
label: 'Antwort gesendet von',
|
||||
admin: {
|
||||
condition: (data, siblingData) => siblingData?.responded,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'responseMethod',
|
||||
type: 'select',
|
||||
label: 'Antwort-Methode',
|
||||
options: [
|
||||
{ label: 'E-Mail', value: 'email' },
|
||||
{ label: 'Telefon', value: 'phone' },
|
||||
{ label: 'Persönlich', value: 'in-person' },
|
||||
{ label: 'Brief', value: 'letter' },
|
||||
],
|
||||
admin: {
|
||||
condition: (data, siblingData) => siblingData?.responded,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'responseSummary',
|
||||
type: 'textarea',
|
||||
label: 'Zusammenfassung der Antwort',
|
||||
admin: {
|
||||
condition: (data, siblingData) => siblingData?.responded,
|
||||
description: 'Kurze Zusammenfassung was geantwortet wurde',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Tags für Kategorisierung
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
label: 'Tags',
|
||||
admin: {
|
||||
description: 'Tags zur Kategorisierung (z.B. "Preisanfrage", "Support")',
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Tag',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Gelesen-Markierung
|
||||
{
|
||||
name: 'readAt',
|
||||
type: 'date',
|
||||
label: 'Gelesen am',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
displayFormat: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'readBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
label: 'Gelesen von',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
132
src/hooks/formSubmissionHooks.ts
Normal file
132
src/hooks/formSubmissionHooks.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// src/hooks/formSubmissionHooks.ts
|
||||
|
||||
import type {
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionAfterReadHook,
|
||||
FieldHook,
|
||||
} from 'payload'
|
||||
|
||||
interface InternalNote {
|
||||
note: string
|
||||
author?: number | string | { id: number | string }
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface FormSubmissionDoc {
|
||||
id: number | string
|
||||
status?: string
|
||||
readAt?: string
|
||||
readBy?: number | string | { id: number | string }
|
||||
internalNotes?: InternalNote[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Setzt automatisch den Autor und Zeitstempel bei neuen Notizen
|
||||
*/
|
||||
export const setNoteMetadata: CollectionBeforeChangeHook<FormSubmissionDoc> = async ({
|
||||
data,
|
||||
req,
|
||||
originalDoc,
|
||||
}) => {
|
||||
if (!data.internalNotes || !req.user) return data
|
||||
|
||||
const originalNotes = originalDoc?.internalNotes || []
|
||||
const newNotes = data.internalNotes || []
|
||||
|
||||
// Finde neue Notizen (die noch keinen Autor haben)
|
||||
const updatedNotes = newNotes.map((note, index) => {
|
||||
const isNew = !note.author && !note.createdAt
|
||||
const isExisting = originalNotes[index]
|
||||
|
||||
if (isNew || (!isExisting && !note.author)) {
|
||||
return {
|
||||
...note,
|
||||
author: req.user?.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
return note
|
||||
})
|
||||
|
||||
return {
|
||||
...data,
|
||||
internalNotes: updatedNotes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Markiert Einsendung als gelesen beim ersten Öffnen
|
||||
*/
|
||||
export const markAsRead: CollectionBeforeChangeHook<FormSubmissionDoc> = async ({
|
||||
data,
|
||||
req,
|
||||
originalDoc,
|
||||
operation,
|
||||
}) => {
|
||||
// Nur beim Update und wenn noch nicht gelesen
|
||||
if (operation !== 'update' || !req.user) return data
|
||||
|
||||
// Wenn noch nicht gelesen, jetzt markieren
|
||||
if (!originalDoc?.readAt) {
|
||||
return {
|
||||
...data,
|
||||
readAt: new Date().toISOString(),
|
||||
readBy: req.user.id,
|
||||
// Setze Status auf "gelesen" wenn noch "neu"
|
||||
status: originalDoc?.status === 'new' ? 'read' : (data.status || originalDoc?.status),
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Setzt Antwort-Zeitstempel automatisch
|
||||
*/
|
||||
export const setResponseTimestamp: CollectionBeforeChangeHook<FormSubmissionDoc> = async ({
|
||||
data,
|
||||
req,
|
||||
originalDoc,
|
||||
}) => {
|
||||
// Prüfe ob "responded" gerade auf true gesetzt wurde
|
||||
const wasResponded = originalDoc?.responseTracking?.responded
|
||||
const isNowResponded = data?.responseTracking?.responded
|
||||
|
||||
if (!wasResponded && isNowResponded && req.user) {
|
||||
return {
|
||||
...data,
|
||||
responseTracking: {
|
||||
...data.responseTracking,
|
||||
respondedAt: new Date().toISOString(),
|
||||
respondedBy: req.user.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Kombinierter BeforeChange Hook
|
||||
*/
|
||||
export const formSubmissionBeforeChange: CollectionBeforeChangeHook<FormSubmissionDoc> = async (
|
||||
args
|
||||
) => {
|
||||
let data = args.data
|
||||
|
||||
// Notiz-Metadaten setzen
|
||||
const result1 = await setNoteMetadata({ ...args, data })
|
||||
data = result1 as FormSubmissionDoc
|
||||
|
||||
// Als gelesen markieren
|
||||
const result2 = await markAsRead({ ...args, data })
|
||||
data = result2 as FormSubmissionDoc
|
||||
|
||||
// Antwort-Zeitstempel setzen
|
||||
const result3 = await setResponseTimestamp({ ...args, data })
|
||||
data = result3 as FormSubmissionDoc
|
||||
|
||||
return data
|
||||
}
|
||||
115
src/migrations/20251210_090000_enhance_form_submissions.ts
Normal file
115
src/migrations/20251210_090000_enhance_form_submissions.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
-- Status Enum
|
||||
CREATE TYPE "public"."enum_form_submissions_status" AS ENUM('new', 'read', 'in-progress', 'waiting', 'completed', 'archived');
|
||||
|
||||
-- Priority Enum
|
||||
CREATE TYPE "public"."enum_form_submissions_priority" AS ENUM('high', 'normal', 'low');
|
||||
|
||||
-- Response Method Enum
|
||||
CREATE TYPE "public"."enum_form_submissions_response_tracking_response_method" AS ENUM('email', 'phone', 'in-person', 'letter');
|
||||
|
||||
-- Add new columns to form_submissions
|
||||
ALTER TABLE "form_submissions"
|
||||
ADD COLUMN "status" "enum_form_submissions_status" DEFAULT 'new',
|
||||
ADD COLUMN "priority" "enum_form_submissions_priority" DEFAULT 'normal',
|
||||
ADD COLUMN "assigned_to_id" integer,
|
||||
ADD COLUMN "read_at" timestamp(3) with time zone,
|
||||
ADD COLUMN "read_by_id" integer,
|
||||
ADD COLUMN "response_tracking_responded" boolean DEFAULT false,
|
||||
ADD COLUMN "response_tracking_responded_at" timestamp(3) with time zone,
|
||||
ADD COLUMN "response_tracking_responded_by_id" integer,
|
||||
ADD COLUMN "response_tracking_response_method" "enum_form_submissions_response_tracking_response_method",
|
||||
ADD COLUMN "response_tracking_response_summary" varchar;
|
||||
|
||||
-- Create internal notes table
|
||||
CREATE TABLE "form_submissions_internal_notes" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"note" varchar NOT NULL,
|
||||
"author_id" integer,
|
||||
"created_at" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
-- Create tags table
|
||||
CREATE TABLE "form_submissions_tags" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"tag" varchar NOT NULL
|
||||
);
|
||||
|
||||
-- Add foreign keys
|
||||
ALTER TABLE "form_submissions"
|
||||
ADD CONSTRAINT "form_submissions_assigned_to_id_users_id_fk"
|
||||
FOREIGN KEY ("assigned_to_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE "form_submissions"
|
||||
ADD CONSTRAINT "form_submissions_read_by_id_users_id_fk"
|
||||
FOREIGN KEY ("read_by_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE "form_submissions"
|
||||
ADD CONSTRAINT "form_submissions_response_tracking_responded_by_id_users_id_fk"
|
||||
FOREIGN KEY ("response_tracking_responded_by_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE "form_submissions_internal_notes"
|
||||
ADD CONSTRAINT "form_submissions_internal_notes_author_id_users_id_fk"
|
||||
FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE "form_submissions_internal_notes"
|
||||
ADD CONSTRAINT "form_submissions_internal_notes_parent_id_fk"
|
||||
FOREIGN KEY ("_parent_id") REFERENCES "public"."form_submissions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE "form_submissions_tags"
|
||||
ADD CONSTRAINT "form_submissions_tags_parent_id_fk"
|
||||
FOREIGN KEY ("_parent_id") REFERENCES "public"."form_submissions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX "form_submissions_status_idx" ON "form_submissions" USING btree ("status");
|
||||
CREATE INDEX "form_submissions_priority_idx" ON "form_submissions" USING btree ("priority");
|
||||
CREATE INDEX "form_submissions_assigned_to_idx" ON "form_submissions" USING btree ("assigned_to_id");
|
||||
CREATE INDEX "form_submissions_internal_notes_order_idx" ON "form_submissions_internal_notes" USING btree ("_order");
|
||||
CREATE INDEX "form_submissions_internal_notes_parent_id_idx" ON "form_submissions_internal_notes" USING btree ("_parent_id");
|
||||
CREATE INDEX "form_submissions_tags_order_idx" ON "form_submissions_tags" USING btree ("_order");
|
||||
CREATE INDEX "form_submissions_tags_parent_id_idx" ON "form_submissions_tags" USING btree ("_parent_id");
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
-- Drop tables
|
||||
DROP TABLE IF EXISTS "form_submissions_tags" CASCADE;
|
||||
DROP TABLE IF EXISTS "form_submissions_internal_notes" CASCADE;
|
||||
|
||||
-- Drop indexes
|
||||
DROP INDEX IF EXISTS "form_submissions_status_idx";
|
||||
DROP INDEX IF EXISTS "form_submissions_priority_idx";
|
||||
DROP INDEX IF EXISTS "form_submissions_assigned_to_idx";
|
||||
|
||||
-- Drop foreign keys and columns
|
||||
ALTER TABLE "form_submissions"
|
||||
DROP CONSTRAINT IF EXISTS "form_submissions_assigned_to_id_users_id_fk",
|
||||
DROP CONSTRAINT IF EXISTS "form_submissions_read_by_id_users_id_fk",
|
||||
DROP CONSTRAINT IF EXISTS "form_submissions_response_tracking_responded_by_id_users_id_fk";
|
||||
|
||||
ALTER TABLE "form_submissions"
|
||||
DROP COLUMN IF EXISTS "status",
|
||||
DROP COLUMN IF EXISTS "priority",
|
||||
DROP COLUMN IF EXISTS "assigned_to_id",
|
||||
DROP COLUMN IF EXISTS "read_at",
|
||||
DROP COLUMN IF EXISTS "read_by_id",
|
||||
DROP COLUMN IF EXISTS "response_tracking_responded",
|
||||
DROP COLUMN IF EXISTS "response_tracking_responded_at",
|
||||
DROP COLUMN IF EXISTS "response_tracking_responded_by_id",
|
||||
DROP COLUMN IF EXISTS "response_tracking_response_method",
|
||||
DROP COLUMN IF EXISTS "response_tracking_response_summary";
|
||||
|
||||
-- Drop enums
|
||||
DROP TYPE IF EXISTS "public"."enum_form_submissions_status";
|
||||
DROP TYPE IF EXISTS "public"."enum_form_submissions_priority";
|
||||
DROP TYPE IF EXISTS "public"."enum_form_submissions_response_tracking_response_method";
|
||||
`)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import * as migration_20251207_205727_audit_logs_collection from './20251207_205
|
|||
import * as migration_20251210_052757_add_faqs_collection from './20251210_052757_add_faqs_collection';
|
||||
import * as migration_20251210_071506_add_team_collection from './20251210_071506_add_team_collection';
|
||||
import * as migration_20251210_073811_add_services_collections from './20251210_073811_add_services_collections';
|
||||
import * as migration_20251210_090000_enhance_form_submissions from './20251210_090000_enhance_form_submissions';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -52,6 +53,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20251210_073811_add_services_collections.up,
|
||||
down: migration_20251210_073811_add_services_collections.down,
|
||||
name: '20251210_073811_add_services_collections'
|
||||
name: '20251210_073811_add_services_collections',
|
||||
},
|
||||
{
|
||||
up: migration_20251210_090000_enhance_form_submissions.up,
|
||||
down: migration_20251210_090000_enhance_form_submissions.down,
|
||||
name: '20251210_090000_enhance_form_submissions',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ import { SEOSettings } from './globals/SEOSettings'
|
|||
|
||||
// Hooks
|
||||
import { sendFormNotification } from './hooks/sendFormNotification'
|
||||
import { formSubmissionBeforeChange } from './hooks/formSubmissionHooks'
|
||||
|
||||
// Form Submissions Overrides
|
||||
import { formSubmissionOverrides } from './collections/FormSubmissionsOverrides'
|
||||
|
||||
// Email
|
||||
import { multiTenantEmailAdapter } from './lib/email/payload-email-adapter'
|
||||
|
|
@ -240,7 +244,9 @@ export default buildConfig({
|
|||
// Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben
|
||||
redirectRelationships: ['pages'],
|
||||
formSubmissionOverrides: {
|
||||
...formSubmissionOverrides,
|
||||
hooks: {
|
||||
beforeChange: [formSubmissionBeforeChange],
|
||||
afterChange: [sendFormNotification],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue