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. 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. 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: Bewerte die Zuordnung mit einer Confidence zwischen 0.3 und 1.0:
- 0.9-1.0: Sehr sicher, Post passt perfekt zum Typ - 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": [ "classifications": [
{{ {{
"post_id": "uuid-des-posts", "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, "confidence": 0.8,
"reasoning": "Kurze Begründung" "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: try:
response = await self.call_openai( response = await self.call_openai(
@@ -230,6 +234,8 @@ Gib ein JSON-Objekt zurück mit diesem Format:
# Process and validate results # Process and validate results
valid_results = [] valid_results = []
unclassified_posts = list(posts) # Track which posts still need classification
for c in classifications: for c in classifications:
post_id = c.get("post_id") post_id = c.get("post_id")
post_type_id = c.get("post_type_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 # Validate post_type_id
if post_type_id and post_type_id != "null" and post_type_id not in valid_type_ids: 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}") 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": if post_type_id and post_type_id != "null":
valid_results.append({ valid_results.append({
@@ -253,6 +260,19 @@ Gib ein JSON-Objekt zurück mit diesem Format:
"classification_method": "semantic", "classification_method": "semantic",
"classification_confidence": min(1.0, max(0.3, confidence)) "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 return valid_results

View File

@@ -22,7 +22,9 @@ class ResearchAgent(BaseAgent):
customer_data: Dict[str, Any], customer_data: Dict[str, Any],
example_posts: List[str] = None, example_posts: List[str] = None,
post_type: Any = 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]: ) -> Dict[str, Any]:
""" """
Research new content topics. Research new content topics.
@@ -34,6 +36,8 @@ class ResearchAgent(BaseAgent):
example_posts: List of the person's actual posts for style reference example_posts: List of the person's actual posts for style reference
post_type: Optional PostType object for targeted research post_type: Optional PostType object for targeted research
post_type_analysis: Optional post type analysis for context 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: Returns:
Research results with suggested topics Research results with suggested topics
@@ -90,7 +94,9 @@ class ResearchAgent(BaseAgent):
example_posts=example_posts or [], example_posts=example_posts or [],
existing_topics=existing_topics, existing_topics=existing_topics,
post_type=post_type, 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( response = await self.call_openai(
@@ -379,7 +385,9 @@ QUALITÄTSKRITERIEN:
example_posts: List[str], example_posts: List[str],
existing_topics: List[str], existing_topics: List[str],
post_type: Any = 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
) -> str: ) -> str:
"""Transform raw research into personalized, concrete topic suggestions.""" """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" 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. return f"""AUFGABE: Transformiere die Recherche-Ergebnisse in KONKRETE, PERSONALISIERTE Themenvorschläge.
{post_type_section} {post_type_section}{strategy_section}
=== RECHERCHE-ERGEBNISSE (Rohdaten) === === RECHERCHE-ERGEBNISSE (Rohdaten) ===
{raw_research} {raw_research}
@@ -492,6 +503,71 @@ WICHTIG:
- Hook-Ideen müssen zum Stil der Beispiel-Posts passen! - Hook-Ideen müssen zum Stil der Beispiel-Posts passen!
- Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen)""" - 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( def _get_structure_prompt(
self, self,
raw_research: str, raw_research: str,

View File

@@ -30,7 +30,8 @@ class WriterAgent(BaseAgent):
post_type_analysis: Optional[Dict[str, Any]] = None, post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "", user_thoughts: str = "",
selected_hook: str = "", selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str: ) -> str:
""" """
Write a LinkedIn post. Write a LinkedIn post.
@@ -46,6 +47,7 @@ class WriterAgent(BaseAgent):
post_type: Optional PostType object for type-specific writing post_type: Optional PostType object for type-specific writing
post_type_analysis: Optional analysis of the post type post_type_analysis: Optional analysis of the post type
company_strategy: Optional company strategy to consider during writing 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: Returns:
Written LinkedIn post Written LinkedIn post
@@ -65,7 +67,8 @@ class WriterAgent(BaseAgent):
post_type_analysis=post_type_analysis, post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts, user_thoughts=user_thoughts,
selected_hook=selected_hook, selected_hook=selected_hook,
company_strategy=company_strategy company_strategy=company_strategy,
strategy_weight=strategy_weight
) )
else: else:
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}") 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, post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts, user_thoughts=user_thoughts,
selected_hook=selected_hook, selected_hook=selected_hook,
company_strategy=company_strategy company_strategy=company_strategy,
strategy_weight=strategy_weight
) )
else: else:
return await self._write_single_draft( return await self._write_single_draft(
@@ -102,7 +106,8 @@ class WriterAgent(BaseAgent):
post_type_analysis=post_type_analysis, post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts, user_thoughts=user_thoughts,
selected_hook=selected_hook, selected_hook=selected_hook,
company_strategy=company_strategy company_strategy=company_strategy,
strategy_weight=strategy_weight
) )
def _select_example_posts( def _select_example_posts(
@@ -236,7 +241,8 @@ class WriterAgent(BaseAgent):
post_type_analysis: Optional[Dict[str, Any]] = None, post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "", user_thoughts: str = "",
selected_hook: str = "", selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str: ) -> str:
""" """
Generate multiple drafts and select the best one. Generate multiple drafts and select the best one.
@@ -249,6 +255,7 @@ class WriterAgent(BaseAgent):
post_type: Optional PostType object post_type: Optional PostType object
post_type_analysis: Optional post type analysis post_type_analysis: Optional post type analysis
company_strategy: Optional company strategy to consider company_strategy: Optional company strategy to consider
strategy_weight: Strategy weight (0.0-1.0) for company strategy influence
Returns: Returns:
Best selected draft 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 num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
logger.info(f"Generating {num_drafts} drafts for selection") 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 # Generate drafts in parallel with different temperatures/approaches
draft_configs = [ 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, post_type_analysis: Optional[Dict[str, Any]] = None,
user_thoughts: str = "", user_thoughts: str = "",
selected_hook: str = "", selected_hook: str = "",
company_strategy: Optional[Dict[str, Any]] = None company_strategy: Optional[Dict[str, Any]] = None,
strategy_weight: float = 0.5
) -> str: ) -> str:
"""Write a single draft (original behavior).""" """Write a single draft (original behavior)."""
# Select examples if not already selected # 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: elif len(selected_examples) > 3:
selected_examples = random.sample(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) 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 # 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, learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None, post_type: Any = None,
post_type_analysis: Optional[Dict[str, 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: ) -> str:
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts.""" """Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
# Extract key profile information # 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 - Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
{lessons_section} {lessons_section}
{post_type_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. 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".""" 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: 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.""" """Build the company strategy section for the system prompt with weighted influence."""
if not company_strategy: if not company_strategy:
return "" 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]): if not any([mission, vision, brand_voice, content_pillars, dos, donts]):
return "" 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: if mission:
section += f"\nMISSION: {mission}" section += f"\nMISSION: {mission}"
@@ -835,7 +862,15 @@ Der Post muss zur Unternehmensstrategie passen, aber authentisch im Stil des Aut
if donts: if donts:
section += f"\n\nDON'Ts (vermeiden):\n" + "\n".join([f" - {d}" for d in 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 return section

View File

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

View File

@@ -342,11 +342,13 @@ class WorkflowOrchestrator:
# Get post type context if specified # Get post type context if specified
post_type = None post_type = None
post_type_analysis = None post_type_analysis = None
strategy_weight = 0.5 # Default strategy weight
if post_type_id: if post_type_id:
post_type = await db.get_post_type(post_type_id) post_type = await db.get_post_type(post_type_id)
if post_type: if post_type:
post_type_analysis = post_type.analysis 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): def report_progress(message: str, step: int, total: int = 4):
if progress_callback: if progress_callback:
@@ -358,6 +360,15 @@ class WorkflowOrchestrator:
if not profile_analysis: if not profile_analysis:
raise ValueError("Profile analysis not found. Run initial setup first.") 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) # Step 2: Get ALL existing topics (from multiple sources to avoid repetition)
report_progress("Lade existierende Topics...", 2) report_progress("Lade existierende Topics...", 2)
existing_topics = set() existing_topics = set()
@@ -384,9 +395,6 @@ class WorkflowOrchestrator:
existing_topics = list(existing_topics) existing_topics = list(existing_topics)
logger.info(f"Found {len(existing_topics)} existing topics to avoid") 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 # 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 is specified, only use posts of that type
if post_type_id: if post_type_id:
@@ -409,7 +417,9 @@ class WorkflowOrchestrator:
customer_data=profile.metadata, customer_data=profile.metadata,
example_posts=example_post_texts, example_posts=example_post_texts,
post_type=post_type, 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 # Step 4: Save research results
@@ -582,11 +592,14 @@ class WorkflowOrchestrator:
# Get post type info if specified # Get post type info if specified
post_type = None post_type = None
post_type_analysis = None post_type_analysis = None
strategy_weight = 0.5 # Default strategy weight
if post_type_id: if post_type_id:
post_type = await db.get_post_type(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 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 # Load user's real posts as style examples
# If post_type_id is specified, only use posts of that type # If post_type_id is specified, only use posts of that type
@@ -642,7 +655,8 @@ class WorkflowOrchestrator:
post_type_analysis=post_type_analysis, post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts, user_thoughts=user_thoughts,
selected_hook=selected_hook, 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: else:
# Revision based on feedback - pass full critic result for structured changes # Revision based on feedback - pass full critic result for structured changes
@@ -660,7 +674,8 @@ class WorkflowOrchestrator:
post_type_analysis=post_type_analysis, post_type_analysis=post_type_analysis,
user_thoughts=user_thoughts, user_thoughts=user_thoughts,
selected_hook=selected_hook, 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) 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): async def run_post_type_analysis(user_id: UUID, job_id: str):
"""Run post type analysis in background.""" """Run post type analysis in background."""
from src.database.client import DatabaseClient from src.database.client import DatabaseClient

View File

@@ -77,11 +77,11 @@
{% for employee in employees %} {% 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 justify-between p-4 bg-brand-bg border border-gray-600 rounded-lg">
<div class="flex items-center gap-3"> <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 %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
<div> <div>

View File

@@ -74,7 +74,7 @@
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="card-bg border rounded-xl p-6 mb-8"> <div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Schnellzugriff</h2> <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"> <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"> <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"> <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> <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> <p class="text-gray-400 mt-1">Verwalte und kategorisiere deine LinkedIn-Posts</p>
</div> </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"> <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> <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 Analyse starten
</button> </button>
</div> </div>
</div>
<!-- Filter & Search Bar --> <!-- Filter & Search Bar -->
<div class="card-bg border rounded-xl p-4 mb-6"> <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 ( from src.services.background_jobs import (
job_manager, JobType, JobStatus, job_manager, JobType, JobStatus,
run_post_scraping, run_profile_analysis, run_post_categorization, run_post_type_analysis, 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 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") @user_router.post("/api/company/invite")
async def send_company_invitation(request: Request): async def send_company_invitation(request: Request):
"""Send invitation to a new employee.""" """Send invitation to a new employee."""