Post Typen Verwalten + Strategy weight

This commit is contained in:
2026-02-14 14:48:03 +01:00
parent 1ebf50ab04
commit 31150000fd
14 changed files with 2624 additions and 43 deletions

View File

@@ -0,0 +1,10 @@
-- Add strategy_weight column to post_types table
-- This column controls how strongly the company strategy influences AI post generation
-- Range: 0.0 (ignore strategy) to 1.0 (strictly follow strategy)
ALTER TABLE post_types
ADD COLUMN IF NOT EXISTS strategy_weight FLOAT DEFAULT 0.5
CHECK (strategy_weight >= 0.0 AND strategy_weight <= 1.0);
-- Set default value for existing rows
UPDATE post_types SET strategy_weight = 0.5 WHERE strategy_weight IS NULL;

View File

@@ -184,8 +184,10 @@ class PostClassifierAgent(BaseAgent):
system_prompt = """Du bist ein Content-Analyst, der LinkedIn-Posts in vordefinierte Kategorien einordnet.
WICHTIG: JEDER Post MUSS kategorisiert werden. Du MUSST für jeden Post den am besten passenden Post-Typ wählen, auch wenn die Übereinstimmung nicht perfekt ist.
Analysiere jeden Post und ordne ihn dem passendsten Post-Typ zu.
Wenn kein Typ wirklich passt, gib "null" als post_type_id zurück.
Es ist NICHT erlaubt, "null" als post_type_id zurückzugeben.
Bewerte die Zuordnung mit einer Confidence zwischen 0.3 und 1.0:
- 0.9-1.0: Sehr sicher, Post passt perfekt zum Typ
@@ -209,12 +211,14 @@ Gib ein JSON-Objekt zurück mit diesem Format:
"classifications": [
{{
"post_id": "uuid-des-posts",
"post_type_id": "uuid-des-typs oder null",
"post_type_id": "uuid-des-typs (IMMER erforderlich, niemals null)",
"confidence": 0.8,
"reasoning": "Kurze Begründung"
}}
]
}}"""
}}
WICHTIG: Jeder Post MUSS einen post_type_id bekommen. Wähle den am besten passenden Typ, auch wenn die Übereinstimmung nicht perfekt ist."""
try:
response = await self.call_openai(
@@ -230,6 +234,8 @@ Gib ein JSON-Objekt zurück mit diesem Format:
# Process and validate results
valid_results = []
unclassified_posts = list(posts) # Track which posts still need classification
for c in classifications:
post_id = c.get("post_id")
post_type_id = c.get("post_type_id")
@@ -244,7 +250,8 @@ Gib ein JSON-Objekt zurück mit diesem Format:
# Validate post_type_id
if post_type_id and post_type_id != "null" and post_type_id not in valid_type_ids:
logger.warning(f"Invalid post_type_id in classification: {post_type_id}")
continue
# Fallback: assign to first available type
post_type_id = list(valid_type_ids - {"null"})[0] if valid_type_ids - {"null"} else None
if post_type_id and post_type_id != "null":
valid_results.append({
@@ -253,6 +260,19 @@ Gib ein JSON-Objekt zurück mit diesem Format:
"classification_method": "semantic",
"classification_confidence": min(1.0, max(0.3, confidence))
})
unclassified_posts = [p for p in unclassified_posts if p.id != matching_post.id]
# FORCE CLASSIFY remaining unclassified posts
if unclassified_posts and post_types:
default_type = post_types[0] # Use first type as fallback
logger.warning(f"Force-classifying {len(unclassified_posts)} posts to default type: {default_type.name}")
for post in unclassified_posts:
valid_results.append({
"post_id": post.id,
"post_type_id": default_type.id,
"classification_method": "semantic_fallback",
"classification_confidence": 0.3
})
return valid_results

View File

@@ -22,7 +22,9 @@ class ResearchAgent(BaseAgent):
customer_data: Dict[str, Any],
example_posts: List[str] = None,
post_type: Any = None,
post_type_analysis: Dict[str, Any] = None
post_type_analysis: Dict[str, Any] = None,
company_strategy: Dict[str, Any] = None,
strategy_weight: float = 0.5
) -> Dict[str, Any]:
"""
Research new content topics.
@@ -34,6 +36,8 @@ class ResearchAgent(BaseAgent):
example_posts: List of the person's actual posts for style reference
post_type: Optional PostType object for targeted research
post_type_analysis: Optional post type analysis for context
company_strategy: Optional company strategy to align topics with
strategy_weight: Strategy weight (0.0-1.0) controlling strategy influence
Returns:
Research results with suggested topics
@@ -90,7 +94,9 @@ class ResearchAgent(BaseAgent):
example_posts=example_posts or [],
existing_topics=existing_topics,
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
company_strategy=company_strategy,
strategy_weight=strategy_weight
)
response = await self.call_openai(
@@ -379,7 +385,9 @@ QUALITÄTSKRITERIEN:
example_posts: List[str],
existing_topics: List[str],
post_type: Any = None,
post_type_analysis: Dict[str, Any] = None
post_type_analysis: Dict[str, Any] = None,
company_strategy: Dict[str, Any] = None,
strategy_weight: float = 0.5
) -> str:
"""Transform raw research into personalized, concrete topic suggestions."""
@@ -428,8 +436,11 @@ QUALITÄTSKRITERIEN:
post_type_section += "\n**WICHTIG:** Alle Themenvorschläge müssen zu diesem Post-Typ passen!\n"
# Build company strategy section
strategy_section = self._get_strategy_section(company_strategy, strategy_weight)
return f"""AUFGABE: Transformiere die Recherche-Ergebnisse in KONKRETE, PERSONALISIERTE Themenvorschläge.
{post_type_section}
{post_type_section}{strategy_section}
=== RECHERCHE-ERGEBNISSE (Rohdaten) ===
{raw_research}
@@ -492,6 +503,71 @@ WICHTIG:
- Hook-Ideen müssen zum Stil der Beispiel-Posts passen!
- Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen)"""
def _get_strategy_section(self, company_strategy: Dict[str, Any] = None, strategy_weight: float = 0.5) -> str:
"""Build company strategy section for research prompt with weighted influence."""
if not company_strategy:
return ""
# Extract strategy elements
mission = company_strategy.get("mission", "")
vision = company_strategy.get("vision", "")
content_pillars = company_strategy.get("content_pillars", [])
target_audience = company_strategy.get("target_audience", "")
dos = company_strategy.get("dos", [])
donts = company_strategy.get("donts", [])
# Build section only if there's meaningful content
if not any([mission, vision, content_pillars, dos, donts]):
return ""
# Determine importance level based on strategy_weight
if strategy_weight >= 0.8:
importance = "KRITISCH - STRIKT BEACHTEN"
instruction = "Alle Themen MÜSSEN sich an der Unternehmensstrategie orientieren! Nur Topics vorschlagen, die perfekt zur Strategie passen."
elif strategy_weight >= 0.6:
importance = "WICHTIG - BERÜCKSICHTIGEN"
instruction = "Priorisiere Topics die zur Unternehmensstrategie passen! Die Strategie soll klar erkennbar sein."
elif strategy_weight >= 0.4:
importance = "ORIENTIERUNG"
instruction = "Berücksichtige die Unternehmensstrategie wo sinnvoll - persönliche Expertise hat Priorität."
elif strategy_weight >= 0.2:
importance = "OPTIONAL"
instruction = "Die Strategie dient als Hintergrundinformation - freie Themenauswahl erlaubt."
else:
importance = "NUR REFERENZ"
instruction = "Ignoriere die Strategie weitgehend - Fokus auf persönliche Themen und Expertise."
section = f"""
=== UNTERNEHMENSSTRATEGIE ({importance}) ===
Strategy Weight: {strategy_weight:.1f} / 1.0
{instruction}
"""
if mission:
section += f"\nMISSION: {mission}"
if vision:
section += f"\nVISION: {vision}"
if content_pillars:
section += f"\nCONTENT PILLARS: {', '.join(content_pillars)}"
if target_audience:
section += f"\nZIELGRUPPE: {target_audience}"
if dos:
section += f"\n\nEMPFOHLENE THEMEN-RICHTUNGEN:\n" + "\n".join([f" + {d}" for d in dos])
if donts:
section += f"\n\nZU VERMEIDENDE THEMEN:\n" + "\n".join([f" - {d}" for d in donts])
# Weight-specific closing instruction
if strategy_weight >= 0.8:
section += "\n\n**KRITISCH:** Alle Topics müssen zur Unternehmensstrategie passen - keine Ausnahmen!"
elif strategy_weight >= 0.6:
section += "\n\n**WICHTIG:** Stelle sicher, dass die Mehrheit der Topics die Unternehmenswerte widerspiegelt!"
elif strategy_weight >= 0.4:
section += "\n\n**BEACHTE:** Integriere die Strategie subtil - persönliche Expertise bleibt wichtig!"
else:
section += "\n\n**REFERENZ:** Die Strategie dient nur als Orientierung - persönliche Topics haben Vorrang!"
return section + "\n"
def _get_structure_prompt(
self,
raw_research: str,

View File

@@ -30,7 +30,8 @@ class WriterAgent(BaseAgent):
post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str:
"""
Write a LinkedIn post.
@@ -46,6 +47,7 @@ class WriterAgent(BaseAgent):
post_type: Optional PostType object for type-specific writing
post_type_analysis: Optional analysis of the post type
company_strategy: Optional company strategy to consider during writing
strategy_weight: Strategy weight (0.0-1.0) controlling how strongly the company strategy influences post generation
Returns:
Written LinkedIn post
@@ -65,7 +67,8 @@ class WriterAgent(BaseAgent):
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
company_strategy=company_strategy,
strategy_weight=strategy_weight
)
else:
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
@@ -90,7 +93,8 @@ class WriterAgent(BaseAgent):
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
company_strategy=company_strategy,
strategy_weight=strategy_weight
)
else:
return await self._write_single_draft(
@@ -102,7 +106,8 @@ class WriterAgent(BaseAgent):
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy
company_strategy=company_strategy,
strategy_weight=strategy_weight
)
def _select_example_posts(
@@ -236,7 +241,8 @@ class WriterAgent(BaseAgent):
post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str:
"""
Generate multiple drafts and select the best one.
@@ -249,6 +255,7 @@ class WriterAgent(BaseAgent):
post_type: Optional PostType object
post_type_analysis: Optional post type analysis
company_strategy: Optional company strategy to consider
strategy_weight: Strategy weight (0.0-1.0) for company strategy influence
Returns:
Best selected draft
@@ -256,7 +263,7 @@ class WriterAgent(BaseAgent):
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
logger.info(f"Generating {num_drafts} drafts for selection")
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy)
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy, strategy_weight)
# Generate drafts in parallel with different temperatures/approaches
draft_configs = [
@@ -500,7 +507,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "",
selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None
company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str:
"""Write a single draft (original behavior)."""
# Select examples if not already selected
@@ -515,7 +523,7 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
elif len(selected_examples) > 3:
selected_examples = random.sample(selected_examples, 3)
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy)
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy, strategy_weight)
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
# OPTIMIERT: Niedrigere Temperature (0.5 statt 0.6) für konsistenteren Stil
@@ -536,7 +544,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None,
company_strategy: Optional[Dict[str, Any]] = None
company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str:
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
# Extract key profile information
@@ -788,13 +797,13 @@ Vermeide IMMER diese KI-typischen Muster:
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
{lessons_section}
{post_type_section}
{self._get_company_strategy_section(company_strategy)}
{self._get_company_strategy_section(company_strategy, strategy_weight)}
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None) -> str:
"""Build the company strategy section for the system prompt."""
def _get_company_strategy_section(self, company_strategy: Optional[Dict[str, Any]] = None, strategy_weight: float = 0.5) -> str:
"""Build the company strategy section for the system prompt with weighted influence."""
if not company_strategy:
return ""
@@ -812,11 +821,29 @@ Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"
if not any([mission, vision, brand_voice, content_pillars, dos, donts]):
return ""
section = """
# Determine importance level and instruction based on strategy_weight
if strategy_weight >= 0.8:
importance = "KRITISCH WICHTIG - STRIKT BEFOLGEN"
instruction = "Der Post MUSS sich eng an die Unternehmensstrategie halten! Die Strategy-Guidelines haben höchste Priorität."
elif strategy_weight >= 0.6:
importance = "WICHTIG - BEACHTEN"
instruction = "Integriere die Unternehmenswerte deutlich, aber bewahre Authentizität! Die Strategie soll klar erkennbar sein."
elif strategy_weight >= 0.4:
importance = "ALS KONTEXT BERÜCKSICHTIGEN"
instruction = "Berücksichtige die Strategie wo sinnvoll - Persönlicher Stil hat Priorität! Subtile Integration genügt."
elif strategy_weight >= 0.2:
importance = "OPTIONALE ORIENTIERUNG"
instruction = "Die Strategie dient als Hintergrundinformation - freie Interpretation erlaubt! Fokus auf Authentizität."
else:
importance = "NUR ALS REFERENZ"
instruction = "Ignoriere die Strategie weitgehend - voller Fokus auf persönlichen Stil! Strategy-Guidelines sind optional."
8. UNTERNEHMENSSTRATEGIE (WICHTIG - IMMER BEACHTEN!):
section = f"""
Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Autors bleiben!
8. UNTERNEHMENSSTRATEGIE ({importance}):
Strategy Weight: {strategy_weight:.1f} / 1.0
{instruction}
"""
if mission:
section += f"\nMISSION: {mission}"
@@ -835,7 +862,15 @@ Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Aut
if donts:
section += f"\n\nDON'Ts (vermeiden):\n" + "\n".join([f" - {d}" for d in donts])
section += "\n\nWICHTIG: Integriere die Unternehmenswerte subtil - der Post soll nicht wie eine Werbung klingen!"
# Weight-specific closing instruction
if strategy_weight >= 0.8:
section += "\n\nKRITISCH: Die Unternehmensstrategie ist nicht verhandelbar - jeder Post muss diese widerspiegeln!"
elif strategy_weight >= 0.6:
section += "\n\nWICHTIG: Stelle sicher, dass die Unternehmenswerte deutlich erkennbar sind!"
elif strategy_weight >= 0.4:
section += "\n\nINTEGRIERE: Berücksichtige die Strategie subtil - authentischer Stil bleibt wichtig!"
else:
section += "\n\nREFERENZ: Die Strategie dient nur als Orientierung - persönlicher Stil hat Vorrang!"
return section

View File

@@ -226,6 +226,7 @@ class PostType(DBModel):
analysis_generated_at: Optional[datetime] = None
analyzed_post_count: int = 0
is_active: bool = True
strategy_weight: float = 0.5
class LinkedInProfile(DBModel):

View File

@@ -342,11 +342,13 @@ class WorkflowOrchestrator:
# Get post type context if specified
post_type = None
post_type_analysis = None
strategy_weight = 0.5 # Default strategy weight
if post_type_id:
post_type = await db.get_post_type(post_type_id)
if post_type:
post_type_analysis = post_type.analysis
logger.info(f"Targeting research for post type: {post_type.name}")
strategy_weight = post_type.strategy_weight
logger.info(f"Targeting research for post type: {post_type.name} with strategy weight {strategy_weight:.1f}")
def report_progress(message: str, step: int, total: int = 4):
if progress_callback:
@@ -358,6 +360,15 @@ class WorkflowOrchestrator:
if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.")
# Step 1.5: Load company strategy if user belongs to a company
company_strategy = None
profile = await db.get_profile(user_id)
if profile and profile.company_id:
company = await db.get_company(profile.company_id)
if company and company.company_strategy:
company_strategy = company.company_strategy
logger.info(f"Loaded company strategy for research: {company.name}")
# Step 2: Get ALL existing topics (from multiple sources to avoid repetition)
report_progress("Lade existierende Topics...", 2)
existing_topics = set()
@@ -384,9 +395,6 @@ class WorkflowOrchestrator:
existing_topics = list(existing_topics)
logger.info(f"Found {len(existing_topics)} existing topics to avoid")
# Get profile data
profile = await db.get_profile(user_id)
# Get example posts to understand the person's actual content style
# If post_type_id is specified, only use posts of that type
if post_type_id:
@@ -409,7 +417,9 @@ class WorkflowOrchestrator:
customer_data=profile.metadata,
example_posts=example_post_texts,
post_type=post_type,
post_type_analysis=post_type_analysis
post_type_analysis=post_type_analysis,
company_strategy=company_strategy,
strategy_weight=strategy_weight
)
# Step 4: Save research results
@@ -582,11 +592,14 @@ class WorkflowOrchestrator:
# Get post type info if specified
post_type = None
post_type_analysis = None
strategy_weight = 0.5 # Default strategy weight
if post_type_id:
post_type = await db.get_post_type(post_type_id)
if post_type and post_type.analysis:
if post_type:
if post_type.analysis:
post_type_analysis = post_type.analysis
logger.info(f"Using post type '{post_type.name}' for writing")
strategy_weight = post_type.strategy_weight # Extract strategy weight from post type
logger.info(f"Using post type '{post_type.name}' with strategy weight {strategy_weight:.1f}")
# Load user's real posts as style examples
# If post_type_id is specified, only use posts of that type
@@ -642,7 +655,8 @@ class WorkflowOrchestrator:
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy # NEW: Pass company strategy
company_strategy=company_strategy, # Pass company strategy
strategy_weight=strategy_weight # NEW: Pass strategy weight
)
else:
# Revision based on feedback - pass full critic result for structured changes
@@ -660,7 +674,8 @@ class WorkflowOrchestrator:
post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts,
selected_hook=selected_hook,
company_strategy=company_strategy # NEW: Pass company strategy
company_strategy=company_strategy, # Pass company strategy
strategy_weight=strategy_weight # NEW: Pass strategy weight
)
writer_versions.append(current_post)

View File

@@ -396,6 +396,101 @@ async def run_post_categorization(user_id: UUID, job_id: str):
)
async def run_post_recategorization(user_id: UUID, job_id: str):
"""Re-categorize ALL LinkedIn posts (including already categorized ones)."""
from src.database.client import DatabaseClient
from src.agents.post_classifier import PostClassifierAgent
import asyncio
db = DatabaseClient()
try:
await job_manager.update_job(
job_id,
status=JobStatus.RUNNING,
progress=5,
message="Lade alle LinkedIn Posts..."
)
# Get ALL LinkedIn posts (not just unclassified)
posts = await db.get_linkedin_posts(user_id)
post_types = await db.get_post_types(user_id)
if not posts:
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message="Keine Posts gefunden!"
)
return
if not post_types:
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error="Keine Post-Typen definiert"
)
return
# FIRST: Mark all posts as uncategorized before re-categorizing
await job_manager.update_job(
job_id,
progress=15,
message=f"Markiere {len(posts)} Posts als unkategorisiert..."
)
# Set post_type_id to NULL for all posts
for post in posts:
try:
await asyncio.to_thread(
lambda p=post: db.client.table("linkedin_posts").update({
"post_type_id": None
}).eq("id", str(p.id)).execute()
)
except Exception as e:
logger.warning(f"Failed to unclassify post {post.id}: {e}")
logger.info(f"Marked {len(posts)} posts as uncategorized")
await job_manager.update_job(
job_id,
progress=30,
message=f"Re-kategorisiere {len(posts)} Posts..."
)
# Run classification on ALL posts
classifier = PostClassifierAgent()
classifications = await classifier.process(posts, post_types)
await job_manager.update_job(
job_id,
progress=70,
message="Speichere neue Kategorisierungen..."
)
# Save classifications
if classifications:
await db.update_posts_classification_bulk(classifications)
await job_manager.update_job(
job_id,
status=JobStatus.COMPLETED,
progress=100,
message=f"{len(classifications)} Posts re-kategorisiert!"
)
logger.info(f"Post re-categorization completed for user {user_id}: {len(classifications)} posts")
except Exception as e:
logger.error(f"Post re-categorization failed: {e}")
await job_manager.update_job(
job_id,
status=JobStatus.FAILED,
error=str(e)
)
async def run_post_type_analysis(user_id: UUID, job_id: str):
"""Run post type analysis in background."""
from src.database.client import DatabaseClient

View File

@@ -77,11 +77,11 @@
{% for employee in employees %}
<div class="flex items-center justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
<div class="w-10 h-10 rounded-full bg-brand-highlight/20 flex items-center justify-center overflow-hidden">
{% if employee.linkedin_picture %}
<img src="{{ employee.linkedin_picture }}" alt="{{ employee.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
<img src="{{ employee.linkedin_picture }}" alt="" class="w-10 h-10 rounded-full">
{% else %}
<span class="text-brand-bg-dark font-bold">{{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }}</span>
<span class="text-brand-highlight font-bold">{{ (employee.display_name or employee.linkedin_name or employee.email)[0] | upper }}</span>
{% endif %}
</div>
<div>

View File

@@ -74,7 +74,7 @@
<!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg-dark rounded-lg hover:bg-brand-bg-light transition-colors">
<div class="w-10 h-10 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -0,0 +1,955 @@
{% extends "base.html" %}
{% block title %}Post-Typen verwalten{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-4">
<a href="#" onclick="handleBackNavigation(event)" class="text-gray-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<div class="flex-1">
<h1 class="text-2xl font-bold text-white">Post-Typen verwalten</h1>
<p class="text-gray-400 mt-1">Definiere und konfiguriere deine Post-Kategorien</p>
</div>
<button onclick="openCreateModal()" class="btn-primary px-5 py-2.5 rounded-lg font-medium flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Neuer Post-Typ
</button>
</div>
</div>
<!-- Strategy Info Banner -->
{% if has_strategy %}
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-brand-highlight flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="text-brand-highlight font-medium mb-1">Strategy Weight erklärt</p>
<p class="text-gray-300 text-sm">
Der <strong>Strategy Weight</strong> steuert, wie stark die KI bei der Post-Generierung die Unternehmensstrategie berücksichtigt:
<span class="text-gray-400">0.0 = komplett ignorieren</span> |
<span class="text-gray-300">0.5 = ausgewogen</span> |
<span class="text-brand-highlight">1.0 = strikt befolgen</span>
</p>
</div>
</div>
</div>
{% endif %}
<!-- Empty State -->
{% if not post_types_with_counts %}
<div class="card-bg border rounded-xl p-12 text-center">
<div class="w-20 h-20 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-10 h-10 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2">Noch keine Post-Typen definiert</h3>
<p class="text-gray-400 mb-6">Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.</p>
<button onclick="openCreateModal()" class="btn-primary px-6 py-3 rounded-lg font-medium">
Ersten Post-Typ erstellen
</button>
</div>
{% endif %}
<!-- Post Types List -->
<div class="space-y-4" id="post-types-list">
<!-- Will be rendered by JavaScript -->
</div>
<!-- Initial data from server (hidden div as backup) -->
<div id="initial-post-types" style="display:none;" data-json="{{ post_types_json | escape }}"></div>
</div>
<script>
// Load initial data
const INITIAL_POST_TYPES = {{ post_types_json | safe }};
console.log('Loaded INITIAL_POST_TYPES:', INITIAL_POST_TYPES);
// ========== MODAL FUNCTIONS ==========
function openCreateModal() {
document.getElementById('modal-title').textContent = 'Neuer Post-Typ';
document.getElementById('edit-id').value = '';
document.getElementById('edit-name').value = '';
document.getElementById('edit-description').value = '';
document.getElementById('edit-weight').value = '0.5';
updateModalWeightDisplay(0.5);
isEditMode = false;
currentEditId = null;
document.getElementById('modal-step-1').classList.remove('hidden');
document.getElementById('edit-form').classList.add('hidden');
document.getElementById('edit-modal').classList.remove('hidden');
document.getElementById('edit-modal').classList.add('flex');
}
function selectPredefinedType(typeName) {
const descriptions = {
'Tough Leadership': 'Meinungsbeiträge und Einblicke zu Branchenthemen, eigene Perspektiven und Expertenwissen',
'Company Updates': 'Neuigkeiten über das Unternehmen, Meilensteine, Produktankündigungen und Teamerfolge',
'Educational Content': 'Tutorials, How-Tos, Tipps & Tricks, Wissen teilen und Mehrwert bieten',
'Personal Story': 'Persönliche Erfahrungen, Learnings, Karrieremomente und authentische Geschichten',
'Industry News': 'Aktuelle Branchennews, Trends, Marktentwicklungen und relevante Ereignisse',
'Engagement Post': 'Umfragen, Fragen an die Community, Diskussionsanregungen und interaktive Inhalte'
};
document.getElementById('edit-name').value = typeName;
document.getElementById('edit-description').value = descriptions[typeName] || '';
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
}
function goToCustomType() {
document.getElementById('edit-name').value = '';
document.getElementById('edit-description').value = '';
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
}
function backToStep1() {
document.getElementById('modal-step-1').classList.remove('hidden');
document.getElementById('edit-form').classList.add('hidden');
}
function closeEditModal() {
document.getElementById('edit-modal').classList.add('hidden');
document.getElementById('edit-modal').classList.remove('flex');
}
function updateModalWeightDisplay(value) {
document.getElementById('modal-weight-value').textContent = parseFloat(value).toFixed(1);
}
async function submitPostType(event) {
event.preventDefault();
const id = document.getElementById('edit-id').value;
const name = document.getElementById('edit-name').value.trim();
const description = document.getElementById('edit-description').value.trim();
const strategyWeight = parseFloat(document.getElementById('edit-weight').value);
if (name.length < 3) {
showToast('Name muss mindestens 3 Zeichen lang sein', 'error');
return;
}
// Check for duplicate names (case-insensitive, only active post types)
const activePostTypes = postTypesState.filter(pt => pt._status !== 'deleted');
const duplicateName = activePostTypes.find(pt => {
// When editing, allow same name for the current post type
if (isEditMode && id && pt.id === id) {
return false;
}
return pt.name.toLowerCase() === name.toLowerCase();
});
if (duplicateName) {
showToast(`Ein Post-Typ mit dem Namen "${duplicateName.name}" existiert bereits`, 'error');
return;
}
if (isEditMode && id) {
updatePostTypeState(id, name, description, strategyWeight);
} else {
createPostTypeState(name, description, strategyWeight);
}
closeEditModal();
}
function closeDeleteModal() {
document.getElementById('delete-modal').classList.add('hidden');
document.getElementById('delete-modal').classList.remove('flex');
}
// ========== TOAST SYSTEM ==========
function showToast(message, type = 'info') {
const toast = document.createElement('div');
const colors = {
success: 'bg-green-600 border-green-500',
error: 'bg-red-600 border-red-500',
info: 'bg-blue-600 border-blue-500',
warning: 'bg-yellow-600 border-yellow-500'
};
const icons = {
success: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>',
error: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>',
info: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
warning: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>'
};
toast.className = `fixed bottom-8 right-8 ${colors[type]} text-white px-6 py-4 rounded-xl shadow-2xl border-2 z-50 flex items-center gap-3 transform transition-all duration-300 translate-y-0 opacity-100`;
toast.innerHTML = `
${icons[type]}
<span class="font-medium">${message}</span>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('translate-y-2', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
</script>
<!-- Save All Button (Fixed) -->
{% if post_types_with_counts %}
<div class="fixed bottom-8 right-8 z-50">
<button id="save-all-btn"
onclick="saveAllChanges()"
disabled
class="bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-bold px-6 py-4 rounded-xl shadow-2xl flex items-center gap-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span>Änderungen speichern</span>
</button>
</div>
{% endif %}
</div>
<!-- Create/Edit Modal (Wizard-Style) -->
<div id="edit-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white" id="modal-title">Neuer Post-Typ</h2>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Step 1: Choose Predefined or Custom -->
<div id="modal-step-1" class="modal-step">
<p class="text-gray-400 mb-6">Wähle einen vorgefertigten Post-Typ oder erstelle einen eigenen</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<button type="button" onclick="selectPredefinedType('Tough Leadership')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Tough Leadership</h3>
<p class="text-sm text-gray-400">Meinungsbeiträge und Einblicke zu Branchenthemen, eigene Perspektiven und Expertenwissen</p>
</button>
<button type="button" onclick="selectPredefinedType('Company Updates')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Company Updates</h3>
<p class="text-sm text-gray-400">Neuigkeiten über das Unternehmen, Meilensteine, Produktankündigungen und Teamerfolge</p>
</button>
<button type="button" onclick="selectPredefinedType('Educational Content')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Educational Content</h3>
<p class="text-sm text-gray-400">Tutorials, How-Tos, Tipps & Tricks, Wissen teilen und Mehrwert bieten</p>
</button>
<button type="button" onclick="selectPredefinedType('Personal Story')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Personal Story</h3>
<p class="text-sm text-gray-400">Persönliche Erfahrungen, Learnings, Karrieremomente und authentische Geschichten</p>
</button>
<button type="button" onclick="selectPredefinedType('Industry News')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Industry News</h3>
<p class="text-sm text-gray-400">Aktuelle Branchennews, Trends, Marktentwicklungen und relevante Ereignisse</p>
</button>
<button type="button" onclick="selectPredefinedType('Engagement Post')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Engagement Post</h3>
<p class="text-sm text-gray-400">Umfragen, Fragen an die Community, Diskussionsanregungen und interaktive Inhalte</p>
</button>
</div>
<div class="border-t border-brand-bg-light pt-6">
<button type="button" onclick="goToCustomType()" class="w-full p-6 bg-brand-highlight/10 hover:bg-brand-highlight/20 border-2 border-brand-highlight/30 hover:border-brand-highlight rounded-xl text-center transition-all">
<svg class="w-8 h-8 mx-auto mb-2 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<p class="text-white font-medium">Eigenen Post-Typ erstellen</p>
</button>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button"
onclick="closeEditModal()"
class="px-6 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
<!-- Step 2: Configure Type -->
<form id="edit-form" onsubmit="submitPostType(event)" class="modal-step hidden">
<input type="hidden" id="edit-id" value="">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
<input type="text"
id="edit-name"
required
minlength="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white text-lg"
placeholder="z.B. Tough Leadership">
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Beschreibung</label>
<textarea id="edit-description"
rows="4"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Beschreibe, worum es bei diesem Post-Typ geht..."></textarea>
</div>
<div class="mb-6 bg-brand-bg/30 rounded-lg p-5 border border-brand-bg-light">
<label class="block text-sm font-medium text-gray-300 mb-3 flex items-center justify-between">
<span>Strategy Weight</span>
<span class="text-brand-highlight font-bold text-xl" id="modal-weight-value">0.5</span>
</label>
<input type="range"
id="edit-weight"
min="0"
max="1"
step="0.1"
value="0.5"
oninput="updateModalWeightDisplay(this.value)"
class="w-full h-3 bg-brand-bg-dark rounded-lg appearance-none cursor-pointer slider">
<div class="flex justify-between text-xs text-gray-400 mt-2">
<span>Ignorieren (0.0)</span>
<span>Ausgewogen (0.5)</span>
<span>Strikt (1.0)</span>
</div>
</div>
<div class="flex gap-3">
<button type="button"
onclick="backToStep1()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">
Zurück
</button>
<button type="submit"
class="flex-1 px-6 py-3 bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-bold rounded-lg transition-colors">
Post-Typ erstellen
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-red-500/50 rounded-xl p-6 w-full max-w-md mx-4">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-white mb-2">Post-Typ löschen?</h2>
<p class="text-gray-300" id="delete-message">Möchtest du diesen Post-Typ wirklich löschen?</p>
</div>
</div>
<input type="hidden" id="delete-id" value="">
<div class="flex gap-3">
<button onclick="closeDeleteModal()"
class="flex-1 px-4 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">
Abbrechen
</button>
<button onclick="confirmDelete()"
class="flex-1 px-4 py-2.5 bg-red-600 hover:bg-red-500 text-white font-medium rounded-lg transition-colors">
Trotzdem löschen
</button>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirmation-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-md mx-4 animate-scale-in">
<div class="flex items-start gap-4 mb-6">
<div id="confirmation-icon" class="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0">
<!-- Icon will be inserted by JS -->
</div>
<div class="flex-1">
<h3 id="confirmation-title" class="text-xl font-bold text-white mb-2"></h3>
<p id="confirmation-message" class="text-gray-300 text-sm"></p>
</div>
</div>
<div class="flex gap-3">
<button onclick="closeConfirmationModal()"
class="flex-1 px-4 py-2.5 bg-brand-bg-light hover:bg-brand-bg text-white font-medium rounded-lg transition-colors border border-gray-600">
Abbrechen
</button>
<button id="confirmation-confirm-btn"
onclick="executeConfirmation()"
class="flex-1 px-4 py-2.5 font-medium rounded-lg transition-colors">
Bestätigen
</button>
</div>
</div>
</div>
<style>
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}
<parameter>
</invoke>
/* Custom slider styles */
.slider {
-webkit-appearance: none;
appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #facc15 0%, #fbbf24 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(250, 204, 21, 0.4);
border: 3px solid #1a1f1f;
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
box-shadow: 0 4px 12px rgba(250, 204, 21, 0.6);
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #facc15 0%, #fbbf24 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(250, 204, 21, 0.4);
border: 3px solid #1a1f1f;
transition: all 0.2s ease;
}
.slider::-moz-range-thumb:hover {
box-shadow: 0 4px 12px rgba(250, 204, 21, 0.6);
transform: scale(1.1);
}
.slider::-webkit-slider-track {
background: linear-gradient(to right, #4b5563 0%, #facc15 50%, #10b981 100%);
border-radius: 8px;
}
.slider::-moz-range-track {
background: linear-gradient(to right, #4b5563 0%, #facc15 50%, #10b981 100%);
border-radius: 8px;
}
</style>
<script>
// State management - all changes happen in memory until Save
let postTypesState = [];
let originalPostTypesJSON = '';
let hasUnsavedChanges = false;
let isEditMode = false;
let currentEditId = null;
let tempIdCounter = 0;
let isIntentionalNavigation = false; // Flag to prevent double warning
// Initialize state from server data
function initializeState() {
console.log('Initializing state...');
// Use the direct JavaScript variable
if (typeof INITIAL_POST_TYPES === 'undefined') {
console.error('INITIAL_POST_TYPES not defined!');
showToast('Fehler: Daten konnten nicht geladen werden', 'error');
return;
}
try {
const data = INITIAL_POST_TYPES;
console.log('Loaded data:', data);
if (!Array.isArray(data)) {
throw new Error('Data is not an array');
}
postTypesState = data.map(item => ({
id: item.post_type.id,
name: item.post_type.name,
description: item.post_type.description,
strategy_weight: item.post_type.strategy_weight,
post_count: item.post_count,
_status: 'existing',
_originalWeight: item.post_type.strategy_weight
}));
console.log('State created:', postTypesState);
originalPostTypesJSON = JSON.stringify(postTypesState);
renderPostTypesList();
updateSaveButton();
// No toast on successful load - too noisy
} catch (e) {
console.error('Failed to initialize state:', e);
console.error('Stack:', e.stack);
showToast('Fehler beim Laden der Post-Typen: ' + e.message, 'error');
}
}
// Check if there are unsaved changes
function checkForChanges() {
const currentJSON = JSON.stringify(postTypesState.map(pt => ({
id: pt.id,
name: pt.name,
description: pt.description,
strategy_weight: pt.strategy_weight,
_status: pt._status
})));
hasUnsavedChanges = currentJSON !== originalPostTypesJSON;
updateSaveButton();
}
// Render the post types list from state
function renderPostTypesList() {
const container = document.getElementById('post-types-list');
if (!container) return;
const activePostTypes = postTypesState.filter(pt => pt._status !== 'deleted');
if (activePostTypes.length === 0) {
container.innerHTML = `
<div class="card-bg border rounded-xl p-12 text-center">
<div class="w-20 h-20 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-10 h-10 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2">Noch keine Post-Typen definiert</h3>
<p class="text-gray-400 mb-6">Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.</p>
</div>
`;
return;
}
container.innerHTML = activePostTypes.map(pt => {
const isNew = pt._status === 'new';
const isModified = pt._status === 'modified';
const statusBadge = isNew ? '<span class="text-xs bg-green-600/20 text-green-400 px-2 py-1 rounded ml-2">Neu</span>' :
isModified ? '<span class="text-xs bg-yellow-600/20 text-yellow-400 px-2 py-1 rounded ml-2">Geändert</span>' : '';
return `
<div class="card-bg border rounded-xl p-6 ${isNew ? 'border-green-500/50' : isModified ? 'border-yellow-500/50' : ''}" data-post-type-id="${pt.id}">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-bold text-white mb-1">${escapeHtml(pt.name)}${statusBadge}</h3>
${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''}
</div>
<div class="flex gap-2">
<button onclick="deletePostTypeState('${pt.id}')"
class="px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Löschen
</button>
</div>
</div>
<div class="mb-4 bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
<label class="text-sm font-medium text-gray-300 mb-3 block flex items-center justify-between">
<span>Strategy Weight</span>
<span class="text-brand-highlight font-bold text-lg" id="weight-value-${pt.id}">${pt.strategy_weight.toFixed(1)}</span>
</label>
<input type="range"
id="weight-${pt.id}"
min="0"
max="1"
step="0.1"
value="${pt.strategy_weight}"
oninput="updateStrategyWeightState('${pt.id}', this.value)"
class="w-full h-3 bg-brand-bg-dark rounded-lg appearance-none cursor-pointer slider">
<div class="flex justify-between text-xs text-gray-400 mt-2">
<span>Ignorieren (0.0)</span>
<span>Ausgewogen (0.5)</span>
<span>Strikt (1.0)</span>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span><strong>${pt.post_count || 0}</strong> Posts zugeordnet</span>
</div>
</div>
`;
}).join('');
}
// Update save button state
function updateSaveButton() {
const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return;
const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted');
const hasStructuralChanges = postTypesState.some(pt => pt._status === 'new' || pt._status === 'deleted');
const hasWeightChanges = postTypesState.some(pt => pt._status === 'modified' || (pt._status === 'existing' && pt.strategy_weight !== pt._originalWeight));
if (hasUnsavedChanges || hasStructuralChanges || hasWeightChanges) {
saveBtn.disabled = false;
saveBtn.classList.add('ring-4', 'ring-brand-highlight/50');
const btnText = saveBtn.querySelector('span');
if (btnText) {
btnText.textContent = hasStructuralChanges ? 'Speichern & Re-Kategorisieren' : 'Änderungen speichern';
}
} else {
saveBtn.disabled = true;
saveBtn.classList.remove('ring-4', 'ring-brand-highlight/50');
}
}
// Create new post type (only in state)
function createPostTypeState(name, description, strategyWeight) {
const tempId = `temp_${++tempIdCounter}`;
postTypesState.push({
id: tempId,
name: name,
description: description,
strategy_weight: strategyWeight,
post_count: 0,
_status: 'new',
_originalWeight: strategyWeight
});
renderPostTypesList();
checkForChanges();
// No toast - change is visible in UI with badge
}
// Edit post type (only in state)
function editPostTypeState(id) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
document.getElementById('modal-title').textContent = 'Post-Typ bearbeiten';
document.getElementById('edit-id').value = id;
document.getElementById('edit-name').value = postType.name;
document.getElementById('edit-description').value = postType.description || '';
document.getElementById('edit-weight').value = postType.strategy_weight;
updateModalWeightDisplay(postType.strategy_weight);
isEditMode = true;
currentEditId = id;
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
document.getElementById('edit-modal').classList.remove('hidden');
document.getElementById('edit-modal').classList.add('flex');
}
// Update post type in state
function updatePostTypeState(id, name, description, strategyWeight) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
postType.name = name;
postType.description = description;
postType.strategy_weight = strategyWeight;
if (postType._status === 'existing') {
postType._status = 'modified';
}
renderPostTypesList();
checkForChanges();
// No toast - change is visible in UI with badge
}
// Delete post type (only in state)
function deletePostTypeState(id) {
const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted');
// Prevent deleting last post type
if (activeTypes.length === 1) {
showToast('Du musst mindestens einen Post-Typ behalten!', 'error');
return;
}
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
// Show confirmation if post type has assigned posts
if (postType.post_count > 0) {
showConfirmation({
type: 'danger',
title: 'Post-Typ löschen?',
message: `Dieser Post-Typ hat ${postType.post_count} zugeordnete Posts. Beim Speichern werden diese Posts neu kategorisiert. Möchtest du wirklich löschen?`,
confirmText: 'Löschen',
onConfirm: () => {
performDelete(id);
}
});
} else {
performDelete(id);
}
}
function performDelete(id) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
if (postType._status === 'new') {
// Remove completely if it was never saved
postTypesState = postTypesState.filter(pt => pt.id !== id);
} else {
// Mark as deleted
postType._status = 'deleted';
}
renderPostTypesList();
checkForChanges();
// No toast - change is visible in UI (item removed)
}
// Update strategy weight in state
function updateStrategyWeightState(id, value) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
const numValue = parseFloat(value);
postType.strategy_weight = numValue;
document.getElementById(`weight-value-${id}`).textContent = numValue.toFixed(1);
if (postType._status === 'existing' && numValue !== postType._originalWeight) {
postType._status = 'modified';
}
checkForChanges();
}
// Save all changes to database
async function saveAllChanges() {
const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return;
const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted');
if (activeTypes.length === 0) {
showToast('Du musst mindestens einen Post-Typ behalten!', 'error');
return;
}
saveBtn.disabled = true;
showToast('Speichere Änderungen...', 'info');
try {
const hasStructuralChanges = postTypesState.some(pt => pt._status === 'new' || pt._status === 'deleted');
// 1. Delete removed post types
const deletedTypes = postTypesState.filter(pt => pt._status === 'deleted' && !pt.id.startsWith('temp_'));
for (const pt of deletedTypes) {
await fetch(`/api/employee/post-types/${pt.id}`, { method: 'DELETE' });
}
// 2. Create new post types
const newTypes = postTypesState.filter(pt => pt._status === 'new');
for (const pt of newTypes) {
const response = await fetch('/api/employee/post-types', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pt.name,
description: pt.description,
strategy_weight: pt.strategy_weight
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to create post type');
}
}
// 3. Update modified post types
const modifiedTypes = postTypesState.filter(pt => pt._status === 'modified' && !pt.id.startsWith('temp_'));
for (const pt of modifiedTypes) {
await fetch(`/api/employee/post-types/${pt.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pt.name,
description: pt.description,
strategy_weight: pt.strategy_weight
})
});
}
// 4. Trigger save-all with structural changes flag
const saveAllResponse = await fetch('/api/employee/post-types/save-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ has_structural_changes: hasStructuralChanges })
});
const saveAllResult = await saveAllResponse.json();
if (saveAllResult.success) {
// Update state to mark everything as saved
postTypesState = postTypesState.filter(pt => pt._status !== 'deleted');
postTypesState.forEach(pt => {
pt._status = 'existing';
pt._originalWeight = pt.strategy_weight;
});
originalPostTypesJSON = JSON.stringify(postTypesState);
hasUnsavedChanges = false;
// Re-render to remove status badges
renderPostTypesList();
updateSaveButton();
if (saveAllResult.recategorized) {
showToast('Gespeichert - Rekategorisierung läuft im Hintergrund', 'success');
} else {
showToast('Änderungen gespeichert', 'success');
}
// No reload needed - UI is already up to date!
} else {
throw new Error(saveAllResult.error || 'Save failed');
}
} catch (error) {
console.error('Error saving changes:', error);
showToast('Fehler beim Speichern: ' + error.message, 'error');
saveBtn.disabled = false;
}
}
// ========== CONFIRMATION MODAL ==========
let confirmationCallback = null;
function showConfirmation(options) {
const modal = document.getElementById('confirmation-modal');
const icon = document.getElementById('confirmation-icon');
const title = document.getElementById('confirmation-title');
const message = document.getElementById('confirmation-message');
const confirmBtn = document.getElementById('confirmation-confirm-btn');
// Set content
title.textContent = options.title || 'Bestätigung';
message.textContent = options.message || 'Bist du sicher?';
// Set icon
const iconType = options.type || 'warning'; // warning, danger, info
if (iconType === 'danger') {
icon.className = 'w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 bg-red-500/20';
icon.innerHTML = `<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>`;
confirmBtn.className = 'flex-1 px-4 py-2.5 font-medium rounded-lg transition-colors bg-red-600 hover:bg-red-500 text-white';
} else if (iconType === 'warning') {
icon.className = 'w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 bg-yellow-500/20';
icon.innerHTML = `<svg class="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>`;
confirmBtn.className = 'flex-1 px-4 py-2.5 font-medium rounded-lg transition-colors bg-brand-highlight hover:bg-yellow-500 text-black';
} else {
icon.className = 'w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 bg-blue-500/20';
icon.innerHTML = `<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`;
confirmBtn.className = 'flex-1 px-4 py-2.5 font-medium rounded-lg transition-colors bg-brand-highlight hover:bg-yellow-500 text-black';
}
// Set button text
confirmBtn.textContent = options.confirmText || 'Bestätigen';
// Store callback
confirmationCallback = options.onConfirm || null;
// Show modal
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeConfirmationModal() {
const modal = document.getElementById('confirmation-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
confirmationCallback = null;
}
function executeConfirmation() {
if (confirmationCallback) {
confirmationCallback();
}
closeConfirmationModal();
}
// Handle back navigation with unsaved changes warning
function handleBackNavigation(event) {
event.preventDefault();
if (hasUnsavedChanges) {
showConfirmation({
type: 'warning',
title: 'Ungespeicherte Änderungen',
message: 'Du hast ungespeicherte Änderungen. Wenn du jetzt zurückgehst, gehen diese verloren. Möchtest du wirklich fortfahren?',
confirmText: 'Trotzdem zurück',
onConfirm: () => {
// Set flag to prevent beforeunload from showing browser dialog
isIntentionalNavigation = true;
window.location.href = '/post-types';
}
});
} else {
// No unsaved changes, navigate directly
isIntentionalNavigation = true;
window.location.href = '/post-types';
}
}
// Helper functions
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
initializeState();
// Warn before leaving with unsaved changes (only for browser events like refresh/close)
window.addEventListener('beforeunload', (e) => {
// Don't show browser warning if this is intentional navigation (already confirmed in custom modal)
if (hasUnsavedChanges && !isIntentionalNavigation) {
e.preventDefault();
e.returnValue = '';
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,706 @@
{% extends "base.html" %}
{% block title %}Post-Typen verwalten{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Post-Typen verwalten</h1>
<p class="text-gray-400 mt-1">Definiere und konfiguriere deine Post-Kategorien</p>
</div>
<button onclick="openCreateModal()" class="btn-primary px-5 py-2.5 rounded-lg font-medium flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Neuer Post-Typ
</button>
</div>
<!-- Strategy Info Banner -->
{% if has_strategy %}
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-brand-highlight flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="text-brand-highlight font-medium mb-1">Strategy Weight erklärt</p>
<p class="text-gray-300 text-sm">
Der <strong>Strategy Weight</strong> steuert, wie stark die KI bei der Post-Generierung die Unternehmensstrategie berücksichtigt:
<span class="text-gray-400">0.0 = komplett ignorieren</span> |
<span class="text-gray-300">0.5 = ausgewogen</span> |
<span class="text-brand-highlight">1.0 = strikt befolgen</span>
</p>
</div>
</div>
</div>
{% endif %}
<!-- Empty State -->
{% if not post_types_with_counts %}
<div class="card-bg border rounded-xl p-12 text-center">
<div class="w-20 h-20 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-10 h-10 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2">Noch keine Post-Typen definiert</h3>
<p class="text-gray-400 mb-6">Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.</p>
<button onclick="openCreateModal()" class="btn-primary px-6 py-3 rounded-lg font-medium">
Ersten Post-Typ erstellen
</button>
</div>
{% endif %}
<!-- Post Types List -->
<div class="space-y-4" id="post-types-list">
{% for item in post_types_with_counts %}
{% set pt = item.post_type %}
<div class="card-bg border rounded-xl p-6" data-post-type-id="{{ pt.id }}">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-bold text-white mb-1">{{ pt.name }}</h3>
{% if pt.description %}
<p class="text-gray-400 text-sm">{{ pt.description }}</p>
{% endif %}
</div>
<div class="flex gap-2">
<button onclick="editPostType('{{ pt.id }}', '{{ pt.name }}', '{{ pt.description or '' }}', {{ pt.strategy_weight }})"
class="px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-brand-bg-light rounded-lg transition-colors">
Bearbeiten
</button>
<button onclick="deletePostType('{{ pt.id }}')"
class="px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Löschen
</button>
</div>
</div>
<!-- Strategy Weight Slider -->
<div class="mb-4 bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
<label class="text-sm font-medium text-gray-300 mb-3 block flex items-center justify-between">
<span>Strategy Weight</span>
<span class="text-brand-highlight font-bold text-lg" id="weight-value-{{ pt.id }}">{{ "%.1f"|format(pt.strategy_weight) }}</span>
</label>
<input type="range"
id="weight-{{ pt.id }}"
min="0"
max="1"
step="0.1"
value="{{ pt.strategy_weight }}"
onchange="updateStrategyWeight('{{ pt.id }}', this.value)"
class="w-full h-3 bg-brand-bg-dark rounded-lg appearance-none cursor-pointer slider">
<div class="flex justify-between text-xs text-gray-400 mt-2">
<span>Ignorieren (0.0)</span>
<span>Ausgewogen (0.5)</span>
<span>Strikt (1.0)</span>
</div>
</div>
<!-- Post Count -->
<div class="flex items-center gap-2 text-sm text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span><strong>{{ item.post_count }}</strong> Posts zugeordnet</span>
</div>
</div>
{% endfor %}
</div>
<!-- Save All Button (Fixed) -->
{% if post_types_with_counts %}
<div class="fixed bottom-8 right-8 z-50">
<button id="save-all-btn"
onclick="saveAllChanges()"
class="bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-bold px-6 py-4 rounded-xl shadow-2xl flex items-center gap-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span>Änderungen speichern</span>
</button>
</div>
{% endif %}
</div>
<!-- Create/Edit Modal (Wizard-Style) -->
<div id="edit-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-gray-600 rounded-xl p-8 w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-white" id="modal-title">Neuer Post-Typ</h2>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Step 1: Choose Predefined or Custom -->
<div id="modal-step-1" class="modal-step">
<p class="text-gray-400 mb-6">Wähle einen vorgefertigten Post-Typ oder erstelle einen eigenen</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<button type="button" onclick="selectPredefinedType('Thought Leadership')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Thought Leadership</h3>
<p class="text-sm text-gray-400">Meinungsbeiträge und Einblicke zu Branchenthemen, eigene Perspektiven und Expertenwissen</p>
</button>
<button type="button" onclick="selectPredefinedType('Company Updates')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Company Updates</h3>
<p class="text-sm text-gray-400">Neuigkeiten über das Unternehmen, Meilensteine, Produktankündigungen und Teamerfolge</p>
</button>
<button type="button" onclick="selectPredefinedType('Educational Content')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Educational Content</h3>
<p class="text-sm text-gray-400">Tutorials, How-Tos, Tipps & Tricks, Wissen teilen und Mehrwert bieten</p>
</button>
<button type="button" onclick="selectPredefinedType('Personal Story')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Personal Story</h3>
<p class="text-sm text-gray-400">Persönliche Erfahrungen, Learnings, Karrieremomente und authentische Geschichten</p>
</button>
<button type="button" onclick="selectPredefinedType('Industry News')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Industry News</h3>
<p class="text-sm text-gray-400">Aktuelle Branchennews, Trends, Marktentwicklungen und relevante Ereignisse</p>
</button>
<button type="button" onclick="selectPredefinedType('Engagement Post')" class="predefined-type-card p-6 bg-brand-bg/50 hover:bg-brand-bg border-2 border-transparent hover:border-brand-highlight rounded-xl text-left transition-all">
<h3 class="text-lg font-bold text-white mb-2">Engagement Post</h3>
<p class="text-sm text-gray-400">Umfragen, Fragen an die Community, Diskussionsanregungen und interaktive Inhalte</p>
</button>
</div>
<div class="border-t border-brand-bg-light pt-6">
<button type="button" onclick="goToCustomType()" class="w-full p-6 bg-brand-highlight/10 hover:bg-brand-highlight/20 border-2 border-brand-highlight/30 hover:border-brand-highlight rounded-xl text-center transition-all">
<svg class="w-8 h-8 mx-auto mb-2 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<p class="text-white font-medium">Eigenen Post-Typ erstellen</p>
</button>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button"
onclick="closeEditModal()"
class="px-6 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
<!-- Step 2: Configure Type -->
<form id="edit-form" onsubmit="submitPostType(event)" class="modal-step hidden">
<input type="hidden" id="edit-id" value="">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
<input type="text"
id="edit-name"
required
minlength="3"
class="w-full input-bg border rounded-lg px-4 py-3 text-white text-lg"
placeholder="z.B. Thought Leadership">
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2">Beschreibung</label>
<textarea id="edit-description"
rows="4"
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
placeholder="Beschreibe, worum es bei diesem Post-Typ geht..."></textarea>
</div>
<div class="mb-6 bg-brand-bg/30 rounded-lg p-5 border border-brand-bg-light">
<label class="block text-sm font-medium text-gray-300 mb-3 flex items-center justify-between">
<span>Strategy Weight</span>
<span class="text-brand-highlight font-bold text-xl" id="modal-weight-value">0.5</span>
</label>
<input type="range"
id="edit-weight"
min="0"
max="1"
step="0.1"
value="0.5"
oninput="updateModalWeightDisplay(this.value)"
class="w-full h-3 bg-brand-bg-dark rounded-lg appearance-none cursor-pointer slider">
<div class="flex justify-between text-xs text-gray-400 mt-2">
<span>Ignorieren (0.0)</span>
<span>Ausgewogen (0.5)</span>
<span>Strikt (1.0)</span>
</div>
</div>
<div class="flex gap-3">
<button type="button"
onclick="backToStep1()"
class="px-6 py-3 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">
Zurück
</button>
<button type="submit"
class="flex-1 px-6 py-3 bg-brand-highlight hover:bg-brand-highlight-dark text-brand-bg-dark font-bold rounded-lg transition-colors">
Post-Typ erstellen
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="bg-brand-bg-dark border border-red-500/50 rounded-xl p-6 w-full max-w-md mx-4">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-bold text-white mb-2">Post-Typ löschen?</h2>
<p class="text-gray-300" id="delete-message">Möchtest du diesen Post-Typ wirklich löschen?</p>
</div>
</div>
<input type="hidden" id="delete-id" value="">
<div class="flex gap-3">
<button onclick="closeDeleteModal()"
class="flex-1 px-4 py-2.5 bg-gray-600 hover:bg-gray-500 text-white rounded-lg transition-colors">
Abbrechen
</button>
<button onclick="confirmDelete()"
class="flex-1 px-4 py-2.5 bg-red-600 hover:bg-red-500 text-white font-medium rounded-lg transition-colors">
Trotzdem löschen
</button>
</div>
</div>
</div>
<style>
/* Custom slider styles */
.slider {
-webkit-appearance: none;
appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #facc15 0%, #fbbf24 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(250, 204, 21, 0.4);
border: 3px solid #1a1f1f;
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
box-shadow: 0 4px 12px rgba(250, 204, 21, 0.6);
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #facc15 0%, #fbbf24 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(250, 204, 21, 0.4);
border: 3px solid #1a1f1f;
transition: all 0.2s ease;
}
.slider::-moz-range-thumb:hover {
box-shadow: 0 4px 12px rgba(250, 204, 21, 0.6);
transform: scale(1.1);
}
.slider::-webkit-slider-track {
background: linear-gradient(to right, #4b5563 0%, #facc15 50%, #10b981 100%);
border-radius: 8px;
}
.slider::-moz-range-track {
background: linear-gradient(to right, #4b5563 0%, #facc15 50%, #10b981 100%);
border-radius: 8px;
}
</style>
<script>
// State Management
let originalPostTypes = []; // Original state from backend
let currentPostTypes = []; // Current working state
let tempIdCounter = 0; // For temporary IDs of new post types
let hasUnsavedChanges = false;
let hasStructuralChanges = false;
let isEditMode = false;
let currentEditId = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
loadInitialState();
setupBeforeUnloadWarning();
});
// Load initial state from DOM
function loadInitialState() {
const postTypeCards = document.querySelectorAll('[data-post-type-id]');
originalPostTypes = [];
postTypeCards.forEach(card => {
const id = card.dataset.postTypeId;
const name = card.querySelector('h3').textContent.trim();
const description = card.querySelector('p.text-gray-400')?.textContent.trim() || '';
const weightValue = card.querySelector('[id^="weight-value-"]').textContent.trim();
const weight = parseFloat(weightValue);
const postCount = parseInt(card.querySelector('strong').textContent);
originalPostTypes.push({
id: id,
name: name,
description: description,
strategy_weight: weight,
post_count: postCount,
is_new: false,
is_deleted: false,
is_modified: false
});
});
// Clone for working state
currentPostTypes = JSON.parse(JSON.stringify(originalPostTypes));
updateSaveButtonState();
}
// Setup warning when leaving page with unsaved changes
function setupBeforeUnloadWarning() {
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
}
// Open create modal
function openCreateModal() {
document.getElementById('modal-title').textContent = 'Neuer Post-Typ';
document.getElementById('edit-id').value = '';
document.getElementById('edit-name').value = '';
document.getElementById('edit-description').value = '';
document.getElementById('edit-weight').value = '0.5';
updateModalWeightDisplay(0.5);
isEditMode = false;
currentEditId = null;
// Show step 1, hide step 2
document.getElementById('modal-step-1').classList.remove('hidden');
document.getElementById('edit-form').classList.add('hidden');
document.getElementById('edit-modal').classList.remove('hidden');
document.getElementById('edit-modal').classList.add('flex');
}
// Select predefined type
function selectPredefinedType(typeName) {
const descriptions = {
'Thought Leadership': 'Meinungsbeiträge und Einblicke zu Branchenthemen, eigene Perspektiven und Expertenwissen',
'Company Updates': 'Neuigkeiten über das Unternehmen, Meilensteine, Produktankündigungen und Teamerfolge',
'Educational Content': 'Tutorials, How-Tos, Tipps & Tricks, Wissen teilen und Mehrwert bieten',
'Personal Story': 'Persönliche Erfahrungen, Learnings, Karrieremomente und authentische Geschichten',
'Industry News': 'Aktuelle Branchennews, Trends, Marktentwicklungen und relevante Ereignisse',
'Engagement Post': 'Umfragen, Fragen an die Community, Diskussionsanregungen und interaktive Inhalte'
};
document.getElementById('edit-name').value = typeName;
document.getElementById('edit-description').value = descriptions[typeName] || '';
// Go to step 2
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
}
// Go to custom type
function goToCustomType() {
document.getElementById('edit-name').value = '';
document.getElementById('edit-description').value = '';
// Go to step 2
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
}
// Back to step 1
function backToStep1() {
document.getElementById('modal-step-1').classList.remove('hidden');
document.getElementById('edit-form').classList.add('hidden');
}
// Edit post type
function editPostType(id, name, description, weight) {
document.getElementById('modal-title').textContent = 'Post-Typ bearbeiten';
document.getElementById('edit-id').value = id;
document.getElementById('edit-name').value = name;
document.getElementById('edit-description').value = description;
document.getElementById('edit-weight').value = weight;
updateModalWeightDisplay(weight);
isEditMode = true;
currentEditId = id;
// Skip step 1 when editing, go straight to form
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
document.getElementById('edit-modal').classList.remove('hidden');
document.getElementById('edit-modal').classList.add('flex');
}
// Close edit modal
function closeEditModal() {
document.getElementById('edit-modal').classList.add('hidden');
document.getElementById('edit-modal').classList.remove('flex');
}
// Update modal weight display
function updateModalWeightDisplay(value) {
document.getElementById('modal-weight-value').textContent = parseFloat(value).toFixed(1);
}
// Submit post type (create or update)
async function submitPostType(event) {
event.preventDefault();
const id = document.getElementById('edit-id').value;
const name = document.getElementById('edit-name').value.trim();
const description = document.getElementById('edit-description').value.trim();
const strategyWeight = parseFloat(document.getElementById('edit-weight').value);
if (name.length < 3) {
showToast('Name muss mindestens 3 Zeichen lang sein', 'error');
return;
}
try {
const data = {
name: name,
description: description || null,
strategy_weight: strategyWeight
};
let response;
if (isEditMode && id) {
// Update existing
response = await fetch(`/api/employee/post-types/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
// Create new
response = await fetch('/api/employee/post-types', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await response.json();
if (result.success) {
// Mark that structural changes happened (only for create, not edit)
if (!isEditMode) {
hasStructuralChanges = true;
localStorage.setItem('postTypesStructuralChanges', 'true');
}
showToast(isEditMode ? 'Post-Typ aktualisiert' : 'Post-Typ erstellt', 'success');
closeEditModal();
setTimeout(() => window.location.reload(), 500);
} else {
showToast(result.error || 'Ein Fehler ist aufgetreten', 'error');
}
} catch (error) {
console.error('Error submitting post type:', error);
showToast('Verbindungsfehler beim Speichern', 'error');
}
}
// Update strategy weight
function updateStrategyWeight(id, value) {
const numValue = parseFloat(value);
document.getElementById(`weight-value-${id}`).textContent = numValue.toFixed(1);
changedWeights.set(id, numValue);
// Update button text and highlight
updateSaveButtonText();
}
// Delete post type
async function deletePostType(id) {
try {
// First check how many posts are affected
const response = await fetch(`/api/employee/post-types/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
// Mark that structural changes happened
hasStructuralChanges = true;
localStorage.setItem('postTypesStructuralChanges', 'true');
if (result.affected_posts > 0) {
// Show confirmation modal
document.getElementById('delete-id').value = id;
document.getElementById('delete-message').textContent =
`Dieser Post-Typ hat ${result.affected_posts} zugeordnete Posts. Wirklich löschen?`;
document.getElementById('delete-modal').classList.remove('hidden');
document.getElementById('delete-modal').classList.add('flex');
} else {
// Direct delete
showToast('Post-Typ gelöscht', 'success');
setTimeout(() => window.location.reload(), 1000);
}
} else {
showToast(result.error || 'Fehler beim Löschen', 'error');
}
} catch (error) {
console.error('Error deleting post type:', error);
showToast('Verbindungsfehler', 'error');
}
}
// Confirm delete from modal
async function confirmDelete() {
const id = document.getElementById('delete-id').value;
closeDeleteModal();
// Delete was already done, just reload
showToast('Post-Typ gelöscht', 'success');
setTimeout(() => window.location.reload(), 1000);
}
// Close delete modal
function closeDeleteModal() {
document.getElementById('delete-modal').classList.add('hidden');
document.getElementById('delete-modal').classList.remove('flex');
}
// Update save button text based on what changed
function updateSaveButtonText() {
const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return;
const btnText = saveBtn.querySelector('span');
if (!btnText) return;
if (hasStructuralChanges) {
btnText.textContent = 'Speichern & Re-Kategorisieren';
saveBtn.classList.add('ring-4', 'ring-brand-highlight/50');
} else if (changedWeights.size > 0) {
btnText.textContent = 'Änderungen speichern';
saveBtn.classList.add('ring-4', 'ring-brand-highlight/50');
} else {
btnText.textContent = 'Änderungen speichern';
saveBtn.classList.remove('ring-4', 'ring-brand-highlight/50');
}
}
// Save all changes (smart save - only re-categorize if structural changes)
async function saveAllChanges() {
const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return;
// Check if there are any changes to save
if (changedWeights.size === 0 && !hasStructuralChanges) {
showToast('Keine Änderungen zu speichern', 'info');
return;
}
saveBtn.disabled = true;
try {
// Save all weight changes first
if (changedWeights.size > 0) {
const updatePromises = Array.from(changedWeights.entries()).map(([id, weight]) => {
return fetch(`/api/employee/post-types/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy_weight: weight })
});
});
await Promise.all(updatePromises);
}
// Trigger save-all with structural changes flag
const response = await fetch('/api/employee/post-types/save-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
has_structural_changes: hasStructuralChanges
})
});
const result = await response.json();
if (result.success) {
changedWeights.clear();
hasStructuralChanges = false;
localStorage.removeItem('postTypesStructuralChanges');
saveBtn.classList.remove('ring-4', 'ring-brand-highlight/50');
if (result.recategorized) {
showToast('Änderungen gespeichert - Rekategorisierung läuft...', 'success');
console.log('Re-categorization started:', result.categorization_job_id);
console.log('Analysis started:', result.analysis_job_id);
} else {
showToast('Änderungen gespeichert', 'success');
console.log('Only weights updated, no re-categorization');
}
// Reload to show updated values
setTimeout(() => window.location.reload(), 1000);
} else {
showToast(result.error || 'Unbekannter Fehler', 'error');
saveBtn.disabled = false;
}
} catch (error) {
console.error('Error saving changes:', error);
showToast('Verbindungsfehler: ' + error.message, 'error');
saveBtn.disabled = false;
}
}
// Toast notification system
function showToast(message, type = 'info') {
const toast = document.createElement('div');
const colors = {
success: 'bg-green-600 border-green-500',
error: 'bg-red-600 border-red-500',
info: 'bg-blue-600 border-blue-500',
warning: 'bg-yellow-600 border-yellow-500'
};
const icons = {
success: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>',
error: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>',
info: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
warning: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>'
};
toast.className = `fixed bottom-8 right-8 ${colors[type]} text-white px-6 py-4 rounded-xl shadow-2xl border-2 z-50 flex items-center gap-3 transform transition-all duration-300 translate-y-0 opacity-100`;
toast.innerHTML = `
${icons[type]}
<span class="font-medium">${message}</span>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('translate-y-2', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,355 @@
// State management - all changes happen in memory until Save
let postTypesState = [];
let originalPostTypesJSON = '';
let hasUnsavedChanges = false;
let isEditMode = false;
let currentEditId = null;
let tempIdCounter = 0;
// Initialize state from server data
function initializeState() {
const initialData = document.getElementById('initial-post-types');
if (!initialData) return;
try {
const data = JSON.parse(initialData.textContent);
postTypesState = data.map(item => ({
id: item.post_type.id,
name: item.post_type.name,
description: item.post_type.description,
strategy_weight: item.post_type.strategy_weight,
post_count: item.post_count,
_status: 'existing', // 'existing', 'new', 'modified', 'deleted'
_originalWeight: item.post_type.strategy_weight
}));
originalPostTypesJSON = JSON.stringify(postTypesState);
renderPostTypesList();
updateSaveButton();
} catch (e) {
console.error('Failed to initialize state:', e);
}
}
// Check if there are unsaved changes
function checkForChanges() {
const currentJSON = JSON.stringify(postTypesState.map(pt => ({
id: pt.id,
name: pt.name,
description: pt.description,
strategy_weight: pt.strategy_weight,
_status: pt._status
})));
hasUnsavedChanges = currentJSON !== originalPostTypesJSON;
updateSaveButton();
}
// Render the post types list from state
function renderPostTypesList() {
const container = document.getElementById('post-types-list');
if (!container) return;
const activePostTypes = postTypesState.filter(pt => pt._status !== 'deleted');
if (activePostTypes.length === 0) {
container.innerHTML = `
<div class="card-bg border rounded-xl p-12 text-center">
<div class="w-20 h-20 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-10 h-10 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2">Noch keine Post-Typen definiert</h3>
<p class="text-gray-400 mb-6">Erstelle deinen ersten Post-Typ, um mit der Kategorisierung zu beginnen.</p>
</div>
`;
return;
}
container.innerHTML = activePostTypes.map(pt => {
const isNew = pt._status === 'new';
const isModified = pt._status === 'modified';
const statusBadge = isNew ? '<span class="text-xs bg-green-600/20 text-green-400 px-2 py-1 rounded ml-2">Neu</span>' :
isModified ? '<span class="text-xs bg-yellow-600/20 text-yellow-400 px-2 py-1 rounded ml-2">Geändert</span>' : '';
return `
<div class="card-bg border rounded-xl p-6 ${isNew ? 'border-green-500/50' : isModified ? 'border-yellow-500/50' : ''}" data-post-type-id="${pt.id}">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-bold text-white mb-1">${escapeHtml(pt.name)}${statusBadge}</h3>
${pt.description ? `<p class="text-gray-400 text-sm">${escapeHtml(pt.description)}</p>` : ''}
</div>
<div class="flex gap-2">
<button onclick="editPostTypeState('${pt.id}')"
class="px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-brand-bg-light rounded-lg transition-colors">
Bearbeiten
</button>
<button onclick="deletePostTypeState('${pt.id}')"
class="px-3 py-2 text-sm text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors">
Löschen
</button>
</div>
</div>
<div class="mb-4 bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
<label class="text-sm font-medium text-gray-300 mb-3 block flex items-center justify-between">
<span>Strategy Weight</span>
<span class="text-brand-highlight font-bold text-lg" id="weight-value-${pt.id}">${pt.strategy_weight.toFixed(1)}</span>
</label>
<input type="range"
id="weight-${pt.id}"
min="0"
max="1"
step="0.1"
value="${pt.strategy_weight}"
oninput="updateStrategyWeightState('${pt.id}', this.value)"
class="w-full h-3 bg-brand-bg-dark rounded-lg appearance-none cursor-pointer slider">
<div class="flex justify-between text-xs text-gray-400 mt-2">
<span>Ignorieren (0.0)</span>
<span>Ausgewogen (0.5)</span>
<span>Strikt (1.0)</span>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span><strong>${pt.post_count || 0}</strong> Posts zugeordnet</span>
</div>
</div>
`;
}).join('');
}
// Update save button state
function updateSaveButton() {
const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return;
const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted');
const hasStructuralChanges = postTypesState.some(pt => pt._status === 'new' || pt._status === 'deleted');
const hasWeightChanges = postTypesState.some(pt => pt._status === 'modified' || (pt._status === 'existing' && pt.strategy_weight !== pt._originalWeight));
if (hasUnsavedChanges || hasStructuralChanges || hasWeightChanges) {
saveBtn.disabled = false;
saveBtn.classList.add('ring-4', 'ring-brand-highlight/50');
const btnText = saveBtn.querySelector('span');
if (btnText) {
btnText.textContent = hasStructuralChanges ? 'Speichern & Re-Kategorisieren' : 'Änderungen speichern';
}
} else {
saveBtn.disabled = true;
saveBtn.classList.remove('ring-4', 'ring-brand-highlight/50');
}
}
// Create new post type (only in state)
function createPostTypeState(name, description, strategyWeight) {
const tempId = `temp_${++tempIdCounter}`;
postTypesState.push({
id: tempId,
name: name,
description: description,
strategy_weight: strategyWeight,
post_count: 0,
_status: 'new',
_originalWeight: strategyWeight
});
renderPostTypesList();
checkForChanges();
showToast('Post-Typ hinzugefügt (noch nicht gespeichert)', 'info');
}
// Edit post type (only in state)
function editPostTypeState(id) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
document.getElementById('modal-title').textContent = 'Post-Typ bearbeiten';
document.getElementById('edit-id').value = id;
document.getElementById('edit-name').value = postType.name;
document.getElementById('edit-description').value = postType.description || '';
document.getElementById('edit-weight').value = postType.strategy_weight;
updateModalWeightDisplay(postType.strategy_weight);
isEditMode = true;
currentEditId = id;
document.getElementById('modal-step-1').classList.add('hidden');
document.getElementById('edit-form').classList.remove('hidden');
document.getElementById('edit-modal').classList.remove('hidden');
document.getElementById('edit-modal').classList.add('flex');
}
// Update post type in state
function updatePostTypeState(id, name, description, strategyWeight) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
postType.name = name;
postType.description = description;
postType.strategy_weight = strategyWeight;
if (postType._status === 'existing') {
postType._status = 'modified';
}
renderPostTypesList();
checkForChanges();
showToast('Post-Typ aktualisiert (noch nicht gespeichert)', 'info');
}
// Delete post type (only in state)
function deletePostTypeState(id) {
const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted');
// Prevent deleting last post type
if (activeTypes.length === 1) {
showToast('Du musst mindestens einen Post-Typ behalten!', 'error');
return;
}
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
if (postType.post_count > 0) {
if (!confirm(`Dieser Post-Typ hat ${postType.post_count} zugeordnete Posts. Wirklich löschen?`)) {
return;
}
}
if (postType._status === 'new') {
// Remove completely if it was never saved
postTypesState = postTypesState.filter(pt => pt.id !== id);
} else {
// Mark as deleted
postType._status = 'deleted';
}
renderPostTypesList();
checkForChanges();
showToast('Post-Typ gelöscht (noch nicht gespeichert)', 'info');
}
// Update strategy weight in state
function updateStrategyWeightState(id, value) {
const postType = postTypesState.find(pt => pt.id === id);
if (!postType) return;
const numValue = parseFloat(value);
postType.strategy_weight = numValue;
document.getElementById(`weight-value-${id}`).textContent = numValue.toFixed(1);
if (postType._status === 'existing' && numValue !== postType._originalWeight) {
postType._status = 'modified';
}
checkForChanges();
}
// Save all changes to database
async function saveAllChanges() {
const saveBtn = document.getElementById('save-all-btn');
if (!saveBtn) return;
const activeTypes = postTypesState.filter(pt => pt._status !== 'deleted');
if (activeTypes.length === 0) {
showToast('Du musst mindestens einen Post-Typ behalten!', 'error');
return;
}
saveBtn.disabled = true;
showToast('Speichere Änderungen...', 'info');
try {
const hasStructuralChanges = postTypesState.some(pt => pt._status === 'new' || pt._status === 'deleted');
// 1. Delete removed post types
const deletedTypes = postTypesState.filter(pt => pt._status === 'deleted' && !pt.id.startsWith('temp_'));
for (const pt of deletedTypes) {
await fetch(`/api/employee/post-types/${pt.id}`, { method: 'DELETE' });
}
// 2. Create new post types
const newTypes = postTypesState.filter(pt => pt._status === 'new');
for (const pt of newTypes) {
const response = await fetch('/api/employee/post-types', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pt.name,
description: pt.description,
strategy_weight: pt.strategy_weight
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to create post type');
}
}
// 3. Update modified post types
const modifiedTypes = postTypesState.filter(pt => pt._status === 'modified' && !pt.id.startsWith('temp_'));
for (const pt of modifiedTypes) {
await fetch(`/api/employee/post-types/${pt.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pt.name,
description: pt.description,
strategy_weight: pt.strategy_weight
})
});
}
// 4. Trigger save-all with structural changes flag
const saveAllResponse = await fetch('/api/employee/post-types/save-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ has_structural_changes: hasStructuralChanges })
});
const saveAllResult = await saveAllResponse.json();
if (saveAllResult.success) {
if (saveAllResult.recategorized) {
showToast('Änderungen gespeichert - Rekategorisierung läuft...', 'success');
} else {
showToast('Änderungen erfolgreich gespeichert', 'success');
}
// Reload page to get fresh data
setTimeout(() => window.location.reload(), 1000);
} else {
throw new Error(saveAllResult.error || 'Save failed');
}
} catch (error) {
console.error('Error saving changes:', error);
showToast('Fehler beim Speichern: ' + error.message, 'error');
saveBtn.disabled = false;
}
}
// Helper functions
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
initializeState();
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
});

View File

@@ -10,11 +10,20 @@
<h1 class="text-2xl font-bold text-white">Post-Typen</h1>
<p class="text-gray-400 mt-1">Verwalte und kategorisiere deine LinkedIn-Posts</p>
</div>
<div class="flex gap-3">
<a href="/post-types/manage" class="px-5 py-2.5 rounded-lg font-medium flex items-center gap-2 bg-brand-bg-dark border border-brand-bg-light text-white hover:bg-brand-bg-light transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Post-Typen verwalten
</a>
<button onclick="runAnalysis()" id="analyze-btn" class="btn-primary px-5 py-2.5 rounded-lg font-medium flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Analyse starten
</button>
</div>
</div>
<!-- Filter & Search Bar -->
<div class="card-bg border rounded-xl p-4 mb-6">

View File

@@ -34,7 +34,7 @@ from src.services.email_service import (
from src.services.background_jobs import (
job_manager, JobType, JobStatus,
run_post_scraping, run_profile_analysis, run_post_categorization, run_post_type_analysis,
run_full_analysis_pipeline
run_full_analysis_pipeline, run_post_recategorization
)
from src.services.storage_service import storage
@@ -2948,6 +2948,310 @@ async def employee_strategy_page(request: Request):
})
# ============================================================================
# EMPLOYEE POST TYPES MANAGEMENT
# ============================================================================
@user_router.get("/post-types/manage")
async def employee_post_types_page(request: Request):
"""Employee post types management page with strategy weight configuration."""
session = require_user_session(request)
if not session:
return RedirectResponse(url="/login", status_code=302)
if session.account_type != "employee":
raise HTTPException(status_code=403, detail="Only employees can access this page")
try:
user_id = UUID(session.user_id)
# Get all active post types for this employee
post_types = await db.get_post_types(user_id, active_only=True)
# Count posts for each post type and convert to JSON-serializable format
post_types_with_counts = []
for pt in post_types:
posts = await db.get_posts_by_type(user_id, pt.id)
post_types_with_counts.append({
"post_type": {
"id": str(pt.id),
"name": pt.name,
"description": pt.description,
"strategy_weight": pt.strategy_weight,
"is_active": pt.is_active
},
"post_count": len(posts)
})
# Check if company strategy exists
company_strategy = None
has_strategy = False
if session.company_id:
company_id = UUID(session.company_id)
company = await db.get_company(company_id)
if company and company.company_strategy:
company_strategy = company.company_strategy
has_strategy = True
profile_picture = await get_user_avatar(session, user_id)
# Convert to JSON string for JavaScript
import json
post_types_json = json.dumps(post_types_with_counts)
logger.info(f"Generated JSON for {len(post_types_with_counts)} post types")
logger.info(f"JSON length: {len(post_types_json)} characters")
logger.info(f"JSON preview: {post_types_json[:200] if len(post_types_json) > 200 else post_types_json}")
return templates.TemplateResponse("employee_post_types.html", {
"request": request,
"page": "post_types",
"session": session,
"post_types_with_counts": post_types_with_counts,
"post_types_json": post_types_json,
"has_strategy": has_strategy,
"company_strategy": company_strategy,
"profile_picture": profile_picture
})
except Exception as e:
logger.error(f"Error loading employee post types page: {e}")
profile_picture = await get_user_avatar(session, UUID(session.user_id))
import json
return templates.TemplateResponse("employee_post_types.html", {
"request": request,
"page": "post_types",
"session": session,
"post_types_with_counts": [],
"post_types_json": json.dumps([]),
"has_strategy": False,
"company_strategy": None,
"error": str(e),
"profile_picture": profile_picture
})
@user_router.post("/api/employee/post-types")
async def create_employee_post_type(request: Request, background_tasks: BackgroundTasks):
"""Create a new post type for an employee."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can create post types"}, status_code=403)
try:
data = await request.json()
user_id = UUID(session.user_id)
# Validate required fields
name_raw = data.get("name")
name = name_raw.strip() if name_raw else ""
if not name or len(name) < 3:
return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400)
description_raw = data.get("description")
description = description_raw.strip() if description_raw else ""
strategy_weight = float(data.get("strategy_weight", 0.5))
# Validate strategy_weight range
if not (0.0 <= strategy_weight <= 1.0):
return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400)
# Check if an inactive post type with this name already exists
existing_inactive = None
try:
all_post_types = await db.get_post_types(user_id, active_only=False)
existing_inactive = next((pt for pt in all_post_types if pt.name.lower() == name.lower() and not pt.is_active), None)
except Exception as check_error:
logger.warning(f"Could not check for inactive post types: {check_error}")
if existing_inactive:
# Reactivate the existing post type instead of creating a new one
await db.update_post_type(existing_inactive.id, {
"is_active": True,
"description": description if description else existing_inactive.description,
"strategy_weight": strategy_weight
})
created_post_type = await db.get_post_type(existing_inactive.id)
logger.info(f"Reactivated post type '{name}' for user {user_id}")
else:
# Create new post type
from src.database.models import PostType
post_type = PostType(
user_id=user_id,
name=name,
description=description if description else None,
strategy_weight=strategy_weight,
is_active=True
)
created_post_type = await db.create_post_type(post_type)
logger.info(f"Created post type '{name}' for user {user_id}")
return JSONResponse({
"success": True,
"post_type": {
"id": str(created_post_type.id),
"name": created_post_type.name,
"description": created_post_type.description,
"strategy_weight": created_post_type.strategy_weight
}
})
except Exception as e:
logger.error(f"Error creating post type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.put("/api/employee/post-types/{post_type_id}")
async def update_employee_post_type(request: Request, post_type_id: str):
"""Update an existing post type."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can update post types"}, status_code=403)
try:
user_id = UUID(session.user_id)
pt_id = UUID(post_type_id)
# Check ownership
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != user_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
data = await request.json()
updates = {}
# Update name if provided
if "name" in data:
name_raw = data["name"]
name = name_raw.strip() if name_raw else ""
if not name or len(name) < 3:
return JSONResponse({"error": "Name must be at least 3 characters"}, status_code=400)
updates["name"] = name
# Update description if provided
if "description" in data:
desc_raw = data["description"]
updates["description"] = desc_raw.strip() if desc_raw else None
# Update strategy_weight if provided
if "strategy_weight" in data:
strategy_weight = float(data["strategy_weight"])
if not (0.0 <= strategy_weight <= 1.0):
return JSONResponse({"error": "Strategy weight must be between 0.0 and 1.0"}, status_code=400)
updates["strategy_weight"] = strategy_weight
# Apply updates
if updates:
await db.update_post_type(pt_id, updates)
return JSONResponse({"success": True})
except Exception as e:
logger.error(f"Error updating post type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.delete("/api/employee/post-types/{post_type_id}")
async def delete_employee_post_type(request: Request, post_type_id: str, background_tasks: BackgroundTasks):
"""Soft delete a post type (set is_active = False)."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can delete post types"}, status_code=403)
try:
user_id = UUID(session.user_id)
pt_id = UUID(post_type_id)
# Check ownership
post_type = await db.get_post_type(pt_id)
if not post_type or post_type.user_id != user_id:
return JSONResponse({"error": "Post type not found or access denied"}, status_code=404)
# Count affected posts
posts = await db.get_posts_by_type(user_id, pt_id)
affected_count = len(posts)
# Soft delete
await db.update_post_type(pt_id, {"is_active": False})
logger.info(f"Deleted post type '{post_type.name}' for user {user_id}")
return JSONResponse({
"success": True,
"affected_posts": affected_count
})
except Exception as e:
logger.error(f"Error deleting post type: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.post("/api/employee/post-types/save-all")
async def save_all_and_reanalyze(request: Request, background_tasks: BackgroundTasks):
"""Save all changes and conditionally trigger re-categorization based on structural changes."""
session = require_user_session(request)
if not session:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
if session.account_type != "employee":
return JSONResponse({"error": "Only employees can trigger re-analysis"}, status_code=403)
try:
data = await request.json()
has_structural_changes = data.get("has_structural_changes", False)
user_id = UUID(session.user_id)
user_id_str = str(user_id)
# Only trigger re-categorization and analysis if there were structural changes
if has_structural_changes:
# Create background job for post re-categorization (ALL posts)
categorization_job = job_manager.create_job(
job_type=JobType.POST_CATEGORIZATION,
user_id=user_id_str
)
# Create background job for post type analysis
analysis_job = job_manager.create_job(
job_type=JobType.POST_TYPE_ANALYSIS,
user_id=user_id_str
)
# Start background tasks
background_tasks.add_task(run_post_recategorization, user_id, categorization_job.id)
background_tasks.add_task(run_post_type_analysis, user_id, analysis_job.id)
logger.info(f"Started re-analysis jobs for user {user_id}: categorization={categorization_job.id}, analysis={analysis_job.id}")
return JSONResponse({
"success": True,
"recategorized": True,
"categorization_job_id": str(categorization_job.id),
"analysis_job_id": str(analysis_job.id)
})
else:
logger.info(f"No structural changes for user {user_id}, skipping re-analysis")
return JSONResponse({
"success": True,
"recategorized": False,
"message": "Nur Weight-Updates, keine Rekategorisierung notwendig"
})
except Exception as e:
logger.error(f"Error in save-all: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@user_router.post("/api/company/invite")
async def send_company_invitation(request: Request):
"""Send invitation to a new employee."""