feat: Post-Rada Workflow Engine + redesign /nowi-czlonkowie
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- AdmissionWorkflowLog model + migration
- services/admission_workflow.py — auto-extract admitted companies from
  board meeting proceedings, match to existing companies, create
  placeholders, notify Office Managers (in-app + email)
- Hook in meeting_publish_protocol() — triggers workflow in background
- Dashboard /rada/posiedzenia/ID/przyjecia for Office Manager
- /nowi-czlonkowie redesigned: grouped by board meetings, fixed Polish
  characters, removed days-based filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-06 23:51:38 +02:00
parent 3ccd7f837b
commit 90203676e5
7 changed files with 792 additions and 80 deletions

View File

@ -327,6 +327,18 @@ def meeting_publish_protocol(meeting_id):
meeting.updated_at = datetime.now() meeting.updated_at = datetime.now()
db.commit() db.commit()
# Trigger admission workflow in background
user_id = current_user.id
import threading
def _run_admission_workflow():
try:
from services.admission_workflow import run_admission_workflow
run_admission_workflow(meeting_id, user_id)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Admission workflow failed for meeting {meeting_id}: {e}")
threading.Thread(target=_run_admission_workflow, daemon=True).start()
flash(f'Protokół z posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success') flash(f'Protokół z posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success')
current_app.logger.info( current_app.logger.info(
f"Meeting protocol published: {meeting.meeting_identifier} by user {current_user.id}" f"Meeting protocol published: {meeting.meeting_identifier} by user {current_user.id}"
@ -342,6 +354,40 @@ def meeting_publish_protocol(meeting_id):
db.close() db.close()
# =============================================================================
# ADMISSION DASHBOARD
# =============================================================================
@bp.route('/posiedzenia/<int:meeting_id>/przyjecia')
@login_required
@office_manager_required
def meeting_admissions(meeting_id):
"""Dashboard: companies admitted at a board meeting."""
db = SessionLocal()
try:
meeting = db.query(BoardMeeting).filter_by(id=meeting_id).first()
if not meeting:
flash('Posiedzenie nie zostało znalezione.', 'error')
return redirect(url_for('board.index'))
from database import Company, AdmissionWorkflowLog
admitted = db.query(Company).filter(
Company.admitted_at_meeting_id == meeting_id
).order_by(Company.name).all()
workflow_log = db.query(AdmissionWorkflowLog).filter_by(
meeting_id=meeting_id
).order_by(AdmissionWorkflowLog.executed_at.desc()).first()
return render_template('board/meeting_admissions.html',
meeting=meeting,
admitted=admitted,
workflow_log=workflow_log,
)
finally:
db.close()
# ============================================================================= # =============================================================================
# MEETING PDF GENERATION # MEETING PDF GENERATION
# ============================================================================= # =============================================================================

View File

@ -994,22 +994,33 @@ def events():
@bp.route('/nowi-czlonkowie') @bp.route('/nowi-czlonkowie')
@login_required @login_required
def new_members(): def new_members():
"""Lista nowych firm członkowskich""" """Lista nowych członków Izby — pogrupowana wg posiedzeń Rady."""
days = request.args.get('days', 90, type=int)
db = SessionLocal() db = SessionLocal()
try: try:
cutoff_date = datetime.now() - timedelta(days=days) from database import BoardMeeting
from sqlalchemy import exists
new_companies = db.query(Company).filter( # Get meetings that have admitted companies, newest first
Company.status == 'active', meetings = db.query(BoardMeeting).filter(
Company.created_at >= cutoff_date exists().where(Company.admitted_at_meeting_id == BoardMeeting.id)
).order_by(Company.created_at.desc()).all() ).order_by(BoardMeeting.meeting_date.desc()).limit(12).all()
# For each meeting, get admitted companies
meetings_data = []
total = 0
for meeting in meetings:
companies = db.query(Company).filter(
Company.admitted_at_meeting_id == meeting.id
).order_by(Company.name).all()
meetings_data.append({
'meeting': meeting,
'companies': companies
})
total += len(companies)
return render_template('new_members.html', return render_template('new_members.html',
companies=new_companies, meetings_data=meetings_data,
days=days, total=total
total=len(new_companies)
) )
finally: finally:
db.close() db.close()

View File

@ -1989,6 +1989,31 @@ class BoardMeeting(Base):
return sorted(result, key=lambda x: x['user'].name or '') return sorted(result, key=lambda x: x['user'].name or '')
class AdmissionWorkflowLog(Base):
"""Audit log for post-protocol admission workflow runs."""
__tablename__ = 'admission_workflow_logs'
id = Column(Integer, primary_key=True)
meeting_id = Column(Integer, ForeignKey('board_meetings.id'), nullable=False, index=True)
executed_at = Column(DateTime, default=datetime.now)
executed_by = Column(Integer, ForeignKey('users.id'))
extracted_companies = Column(PG_JSONB) # [{"title", "extracted_name", "decision_text"}]
matched_companies = Column(PG_JSONB) # [{"extracted_name", "matched_id", "matched_name", "confidence"}]
created_companies = Column(PG_JSONB) # [{"name", "id", "slug"}]
skipped = Column(PG_JSONB) # [{"name", "reason"}]
status = Column(String(20), default='completed') # completed, partial_error, failed
error_message = Column(Text)
notifications_sent = Column(Integer, default=0)
emails_sent = Column(Integer, default=0)
meeting = relationship('BoardMeeting')
executor = relationship('User', foreign_keys=[executed_by])
class ForumTopicSubscription(Base): class ForumTopicSubscription(Base):
"""Forum topic subscriptions for notifications""" """Forum topic subscriptions for notifications"""
__tablename__ = 'forum_topic_subscriptions' __tablename__ = 'forum_topic_subscriptions'

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS admission_workflow_logs (
id SERIAL PRIMARY KEY,
meeting_id INTEGER NOT NULL REFERENCES board_meetings(id),
executed_at TIMESTAMP DEFAULT NOW(),
executed_by INTEGER REFERENCES users(id),
extracted_companies JSONB,
matched_companies JSONB,
created_companies JSONB,
skipped JSONB,
status VARCHAR(20) DEFAULT 'completed',
error_message TEXT,
notifications_sent INTEGER DEFAULT 0,
emails_sent INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_admission_workflow_meeting ON admission_workflow_logs(meeting_id);
GRANT ALL ON TABLE admission_workflow_logs TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE admission_workflow_logs_id_seq TO nordabiz_app;

View File

@ -0,0 +1,395 @@
"""
Post-Rada Admission Workflow Engine
====================================
Automatically processes board meeting protocols to:
1. Extract admitted companies from proceedings
2. Match them to existing companies in DB
3. Create placeholder profiles for new companies
4. Notify Office Managers about companies needing attention
"""
import re
import logging
from datetime import datetime
from database import SessionLocal, BoardMeeting, Company, AdmissionWorkflowLog, User
from sqlalchemy import func, text
logger = logging.getLogger(__name__)
def run_admission_workflow(meeting_id: int, executed_by_user_id: int) -> dict:
"""Main entry point. Called in background thread after protocol publish."""
db = SessionLocal()
try:
meeting = db.query(BoardMeeting).filter_by(id=meeting_id).first()
if not meeting or not meeting.proceedings:
logger.warning(f"Admission workflow: meeting {meeting_id} not found or no proceedings")
return {'status': 'skipped', 'reason': 'no proceedings'}
# Idempotency: check if already processed
existing_log = db.query(AdmissionWorkflowLog).filter_by(meeting_id=meeting_id).first()
# Extract companies from proceedings
extracted = extract_admitted_companies(meeting.proceedings)
if not extracted:
logger.info(f"Admission workflow: no admissions found in meeting {meeting_id}")
if not existing_log:
log = AdmissionWorkflowLog(
meeting_id=meeting_id,
executed_by=executed_by_user_id,
extracted_companies=[],
status='completed'
)
db.add(log)
db.commit()
return {'status': 'completed', 'extracted': 0}
matched = []
created = []
skipped = []
for item in extracted:
name = item['extracted_name']
# Skip if already linked to this meeting
existing_company = db.query(Company).filter(
Company.admitted_at_meeting_id == meeting_id,
func.lower(Company.name) == func.lower(name)
).first()
if existing_company:
skipped.append({'name': name, 'reason': 'already_linked', 'company_id': existing_company.id})
continue
# Try to match existing company
company, confidence = match_company_by_name(db, name)
if company and confidence >= 0.5:
# Link existing company
link_existing_company(db, company, meeting_id, meeting.meeting_date)
matched.append({
'extracted_name': name,
'matched_id': company.id,
'matched_name': company.name,
'confidence': confidence
})
else:
# Create placeholder
new_company = create_placeholder_company(db, name, meeting_id, meeting.meeting_date)
created.append({
'name': new_company.name,
'id': new_company.id,
'slug': new_company.slug
})
db.flush()
# Send notifications
notif_count, email_count = notify_office_managers(db, meeting, {
'extracted': extracted,
'matched': matched,
'created': created,
'skipped': skipped
})
# Log the workflow run
if existing_log:
# Update existing log
existing_log.extracted_companies = [e for e in extracted]
existing_log.matched_companies = matched
existing_log.created_companies = created
existing_log.skipped = skipped
existing_log.notifications_sent = notif_count
existing_log.emails_sent = email_count
existing_log.executed_at = datetime.now()
existing_log.executed_by = executed_by_user_id
else:
log = AdmissionWorkflowLog(
meeting_id=meeting_id,
executed_by=executed_by_user_id,
extracted_companies=[e for e in extracted],
matched_companies=matched,
created_companies=created,
skipped=skipped,
notifications_sent=notif_count,
emails_sent=email_count,
status='completed'
)
db.add(log)
db.commit()
logger.info(
f"Admission workflow completed for meeting {meeting_id}: "
f"{len(extracted)} extracted, {len(matched)} matched, "
f"{len(created)} created, {len(skipped)} skipped"
)
return {
'status': 'completed',
'extracted': len(extracted),
'matched': len(matched),
'created': len(created),
'skipped': len(skipped)
}
except Exception as e:
logger.error(f"Admission workflow failed for meeting {meeting_id}: {e}", exc_info=True)
try:
db.rollback()
error_log = AdmissionWorkflowLog(
meeting_id=meeting_id,
executed_by=executed_by_user_id,
status='failed',
error_message=str(e)
)
db.add(error_log)
db.commit()
except Exception:
pass
return {'status': 'failed', 'error': str(e)}
finally:
db.close()
def extract_admitted_companies(proceedings: list) -> list:
"""
Parse proceedings JSONB to find admission decisions.
Looks for proceedings where:
- title matches "Prezentacja firmy X -- kandydat" pattern
- decisions contain "Przyjeta/Przyjety jednoglosnie" (not "przeniesiona")
"""
results = []
for i, proc in enumerate(proceedings):
title = proc.get('title', '')
decisions = proc.get('decisions', [])
if isinstance(decisions, str):
decisions = [decisions]
# Check if this is an admission proceeding
is_admission = False
decision_text = ''
for d in decisions:
d_lower = d.lower()
if ('przyjęt' in d_lower and 'jednogłośnie' in d_lower
and 'przeniesion' not in d_lower
and 'program' not in d_lower
and 'protokół' not in d_lower
and 'protokol' not in d_lower):
is_admission = True
decision_text = d
break
if not is_admission:
continue
# Extract company name from title
# Pattern 1: "Prezentacja firmy X -- kandydat na czlonka Izby"
# Pattern 2: "Prezentacja firmy X - kandydat na czlonka Izby"
# Pattern 3: "Prezentacja: X -- coach/mentoring (kandydatka na czlonka Izby)"
# Pattern 4: "Prezentacja i glosowanie nad kandydatami..." (bulk - extract from decisions)
company_name = None
# Try title patterns
for pattern in [
r'[Pp]rezentacja\s+firmy\s+(.+?)\s*[—–\-]\s*kandydat',
r'[Pp]rezentacja:\s+(.+?)\s*[—–\-]\s*',
r'[Pp]rezentacja\s+firmy\s+(.+?)$',
]:
match = re.search(pattern, title)
if match:
company_name = match.group(1).strip()
break
# If no match from title, try to extract from decision text
# Pattern: "Przyjeto jednoglosnie firme X jako nowego czlonka Izby"
if not company_name:
match = re.search(r'[Pp]rzyjęt[oa]\s+jednogłośnie\s+firmę\s+(.+?)\s+jako', decision_text)
if match:
company_name = match.group(1).strip()
if company_name:
# Clean up: remove trailing dots, Sp. z o.o. standardization
company_name = company_name.rstrip('.')
results.append({
'title': title,
'extracted_name': company_name,
'decision_text': decision_text,
'proceeding_index': i
})
return results
def match_company_by_name(db, name: str) -> tuple:
"""
Try to find existing company by name.
Returns (Company or None, confidence float).
"""
# 1. Exact case-insensitive match
exact = db.query(Company).filter(
func.lower(Company.name) == func.lower(name)
).first()
if exact:
return (exact, 1.0)
# 2. ILIKE contains match
ilike = db.query(Company).filter(
Company.name.ilike(f'%{name}%')
).first()
if ilike:
return (ilike, 0.8)
# 3. Reverse ILIKE (DB name contained in extracted name)
# e.g. extracted "Prospoland" matches DB "Pros Poland"
all_companies = db.query(Company.id, Company.name).all()
for c_id, c_name in all_companies:
if c_name and (c_name.lower() in name.lower() or name.lower() in c_name.lower()):
company = db.query(Company).filter_by(id=c_id).first()
return (company, 0.7)
# 4. pg_trgm similarity (if extension available)
try:
result = db.execute(
text("SELECT id, name, similarity(name, :name) as sim FROM companies WHERE similarity(name, :name) > 0.3 ORDER BY sim DESC LIMIT 1"),
{'name': name}
).first()
if result:
company = db.query(Company).filter_by(id=result[0]).first()
return (company, float(result[2]))
except Exception:
pass
return (None, 0.0)
def create_placeholder_company(db, name: str, meeting_id: int, meeting_date) -> Company:
"""Create a minimal placeholder company."""
import unicodedata
# Generate slug
slug = name.lower().strip()
slug = unicodedata.normalize('NFKD', slug).encode('ascii', 'ignore').decode('ascii')
slug = re.sub(r'[^a-z0-9]+', '-', slug).strip('-')
# Ensure unique
base_slug = slug
counter = 1
while db.query(Company).filter_by(slug=slug).first():
slug = f"{base_slug}-{counter}"
counter += 1
company = Company(
name=name,
slug=slug,
status='pending',
data_quality='basic',
admitted_at_meeting_id=meeting_id,
member_since=meeting_date,
)
db.add(company)
db.flush() # Get ID
logger.info(f"Created placeholder company: {name} (ID {company.id}, slug {slug})")
return company
def link_existing_company(db, company, meeting_id: int, meeting_date):
"""Link existing company to a board meeting admission."""
if not company.admitted_at_meeting_id:
company.admitted_at_meeting_id = meeting_id
if not company.member_since:
company.member_since = meeting_date
logger.info(f"Linked company {company.name} (ID {company.id}) to meeting {meeting_id}")
def notify_office_managers(db, meeting, results: dict) -> tuple:
"""Send in-app notifications and emails to Office Managers."""
notif_count = 0
email_count = 0
total = len(results.get('matched', [])) + len(results.get('created', []))
needs_attention = len(results.get('created', []))
if total == 0:
return (0, 0)
# Find Office Managers and Admins
managers = db.query(User).filter(
User.role.in_(['ADMIN', 'OFFICE_MANAGER']),
User.is_active == True # noqa: E712
).all()
meeting_id_str = f"{meeting.meeting_number}/{meeting.year}"
action_url = f"/rada/posiedzenia/{meeting.id}/przyjecia"
title = f"Rada {meeting_id_str} -- nowi czlonkowie"
if needs_attention > 0:
message = f"Przyjeto {total} firm. {needs_attention} wymaga uzupelnienia profilu."
else:
message = f"Przyjeto {total} firm. Wszystkie profile sa juz uzupelnione."
for manager in managers:
try:
from utils.notifications import create_notification
create_notification(
user_id=manager.id,
title=title,
message=message,
notification_type='system',
related_type='board_meeting',
related_id=meeting.id,
action_url=action_url
)
notif_count += 1
except Exception as e:
logger.error(f"Failed to notify user {manager.id}: {e}")
# Send email to managers
try:
from email_service import send_email
# Build HTML table
rows = []
for m in results.get('matched', []):
rows.append(f"<tr><td>{m['matched_name']}</td><td style='color:green;'>Profil istnieje</td></tr>")
for c in results.get('created', []):
rows.append(f"<tr><td>{c['name']}</td><td style='color:orange;'>Wymaga uzupelnienia</td></tr>")
table_html = f"""
<table style="width:100%; border-collapse:collapse; margin:16px 0;">
<thead><tr style="background:#f3f4f6;"><th style="text-align:left;padding:8px;">Firma</th><th style="text-align:left;padding:8px;">Status</th></tr></thead>
<tbody>{''.join(rows)}</tbody>
</table>
"""
body_html = f"""
<p>Na posiedzeniu Rady <strong>{meeting_id_str}</strong> ({meeting.meeting_date.strftime('%d.%m.%Y')})
przyjeto <strong>{total}</strong> nowych czlonkow.</p>
{table_html}
{'<p><strong>Uwaga:</strong> ' + str(needs_attention) + ' firm wymaga uzupelnienia profilu na portalu.</p>' if needs_attention else ''}
<p><a href="https://nordabiznes.pl{action_url}" style="display:inline-block;padding:10px 20px;background:#2563eb;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Przejdz do dashboardu przyjec</a></p>
"""
body_text = f"Na posiedzeniu Rady {meeting_id_str} przyjeto {total} nowych czlonkow. {needs_attention} wymaga uzupelnienia profilu."
for manager in managers:
if manager.email:
try:
send_email(
to=manager.email,
subject=f"[NordaBiz] Rada {meeting_id_str} -- przyjeto {total} nowych czlonkow",
body_text=body_text,
body_html=body_html,
email_type='system',
recipient_name=manager.name
)
email_count += 1
except Exception as e:
logger.error(f"Failed to email {manager.email}: {e}")
except Exception as e:
logger.error(f"Failed to send admission emails: {e}")
return (notif_count, email_count)

View File

@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}Przyjęcia nowych członków — Posiedzenie {{ meeting.meeting_identifier }} — Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admissions-header {
margin-bottom: var(--spacing-xl);
}
.admissions-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.admissions-header p {
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.admissions-stats {
display: flex;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border-radius: var(--radius);
padding: var(--spacing-md) var(--spacing-lg);
border: 1px solid var(--border);
text-align: center;
min-width: 120px;
}
.stat-card .stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary);
}
.stat-card .stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: 2px;
}
.stat-card.pending .stat-value { color: #D97706; }
.stat-card.active .stat-value { color: var(--success); }
.companies-table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
.companies-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.companies-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-top: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
}
.status-badge.active {
background: #D1FAE5;
color: #065F46;
}
.status-badge.pending {
background: #FEF3C7;
color: #92400E;
}
.action-btn {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
text-decoration: none;
border: 1px solid var(--border);
color: var(--text-primary);
transition: var(--transition);
}
.action-btn:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.workflow-log {
margin-top: var(--spacing-xl);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-md);
}
.workflow-log h3 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-sm);
color: var(--text-secondary);
}
.workflow-log .log-item {
font-size: var(--font-size-sm);
color: var(--text-secondary);
padding: 2px 0;
}
</style>
{% endblock %}
{% block content %}
<div class="admissions-header">
<a href="{{ url_for('board.meeting_view', meeting_id=meeting.id) }}" style="color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm);">← Powrót do posiedzenia</a>
<h1>Przyjęcia nowych członków</h1>
<p>Posiedzenie Rady {{ meeting.meeting_identifier }} — {{ meeting.meeting_date.strftime('%d.%m.%Y') }}</p>
</div>
{% set active_count = admitted|selectattr('status', 'equalto', 'active')|list|length %}
{% set pending_count = admitted|length - active_count %}
<div class="admissions-stats">
<div class="stat-card">
<div class="stat-value">{{ admitted|length }}</div>
<div class="stat-label">Przyjętych firm</div>
</div>
<div class="stat-card active">
<div class="stat-value">{{ active_count }}</div>
<div class="stat-label">Profil uzupełniony</div>
</div>
<div class="stat-card pending">
<div class="stat-value">{{ pending_count }}</div>
<div class="stat-label">Wymaga uzupełnienia</div>
</div>
</div>
{% if admitted %}
<table class="companies-table">
<thead>
<tr>
<th>Firma</th>
<th>Status profilu</th>
<th>NIP</th>
<th>Kategoria</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for company in admitted %}
<tr>
<td>
<strong>{{ company.name }}</strong>
{% if company.address_city %}
<br><span style="color: var(--text-muted); font-size: var(--font-size-xs);">{{ company.address_city }}</span>
{% endif %}
</td>
<td>
{% if company.status == 'active' %}
<span class="status-badge active">Aktywny</span>
{% else %}
<span class="status-badge pending">Do uzupełnienia</span>
{% endif %}
</td>
<td>{{ company.nip or '—' }}</td>
<td>{{ company.category.name if company.category else '—' }}</td>
<td>
{% if company.status == 'active' %}
<a href="{{ url_for('public.company_detail_by_slug', slug=company.slug) }}" class="action-btn" target="_blank">Profil</a>
{% else %}
<a href="{{ url_for('admin.admin_company_detail', company_id=company.id) }}" class="action-btn" style="border-color: #D97706; color: #92400E;">Uzupełnij</a>
{% endif %}
<a href="{{ url_for('admin.admin_company_detail', company_id=company.id) }}" class="action-btn">Edytuj</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align: center; padding: var(--spacing-2xl); color: var(--text-secondary); background: var(--surface); border-radius: var(--radius);">
<p>Brak firm przypisanych do tego posiedzenia.</p>
<p style="font-size: var(--font-size-sm); margin-top: var(--spacing-sm);">Firmy zostaną automatycznie przypisane po opublikowaniu protokołu.</p>
</div>
{% endif %}
{% if workflow_log %}
<div class="workflow-log">
<h3>Log workflow przyjęć</h3>
<div class="log-item">Wykonano: {{ workflow_log.executed_at|local_time }}</div>
<div class="log-item">Status: {{ workflow_log.status }}</div>
{% if workflow_log.extracted_companies %}
<div class="log-item">Wyodrębniono z protokołu: {{ workflow_log.extracted_companies|length }} firm</div>
{% endif %}
{% if workflow_log.matched_companies %}
<div class="log-item">Dopasowano do istniejących: {{ workflow_log.matched_companies|length }}</div>
{% endif %}
{% if workflow_log.created_companies %}
<div class="log-item">Utworzono nowe profile: {{ workflow_log.created_companies|length }}</div>
{% endif %}
{% if workflow_log.skipped %}
<div class="log-item">Pominięto (już przypisane): {{ workflow_log.skipped|length }}</div>
{% endif %}
<div class="log-item">Powiadomienia: {{ workflow_log.notifications_sent }} in-app, {{ workflow_log.emails_sent }} email</div>
{% if workflow_log.error_message %}
<div class="log-item" style="color: var(--error);">Błąd: {{ workflow_log.error_message }}</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Nowi czlonkowie - Norda Biznes Partner{% endblock %} {% block title %}Nowi członkowie — Norda Biznes Partner{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@ -13,32 +13,41 @@
color: var(--text-primary); color: var(--text-primary);
} }
.filters { .page-header .subtitle {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
}
.filter-btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--font-size-sm); margin-top: var(--spacing-xs);
transition: var(--transition);
} }
.filter-btn:hover { .stats-summary {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary); background: var(--primary);
border-color: var(--primary);
color: white; color: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.stats-number {
font-size: var(--font-size-3xl);
font-weight: 700;
}
.stats-text {
font-size: var(--font-size-lg);
}
.meeting-section {
margin-bottom: var(--spacing-2xl);
}
.meeting-section h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--border);
} }
.members-grid { .members-grid {
@ -73,6 +82,18 @@
text-transform: uppercase; text-transform: uppercase;
} }
.pending-badge {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
background: #fef3c7;
color: #92400e;
padding: 2px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
}
.member-category { .member-category {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
color: var(--text-secondary); color: var(--text-secondary);
@ -112,10 +133,6 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.member-date {
color: var(--text-secondary);
}
.member-location { .member-location {
color: var(--text-secondary); color: var(--text-secondary);
display: flex; display: flex;
@ -129,59 +146,42 @@
color: var(--text-secondary); color: var(--text-secondary);
background: var(--surface); background: var(--surface);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
grid-column: 1 / -1;
}
.stats-summary {
background: var(--primary);
color: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.stats-number {
font-size: var(--font-size-3xl);
font-weight: 700;
}
.stats-text {
font-size: var(--font-size-lg);
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <div class="page-header">
<h1>Nowi czlonkowie</h1> <h1>Nowi członkowie Izby</h1>
<p class="text-muted">Firmy ktore dolaczylky do Norda Biznes</p> <p class="subtitle">Firmy przyjęte na posiedzeniach Rady Izby Norda Biznes</p>
</div> </div>
{% if meetings_data %}
<div class="stats-summary"> <div class="stats-summary">
<div class="stats-number">{{ total }}</div> <div class="stats-number">{{ total }}</div>
<div class="stats-text">nowych firm w ciagu ostatnich {{ days }} dni</div> <div class="stats-text">nowych firm przyjętych na {{ meetings_data|length }} posiedzeniach Rady</div>
</div> </div>
<div class="filters"> {% for item in meetings_data %}
<a href="{{ url_for('new_members', days=30) }}" class="filter-btn {% if days == 30 %}active{% endif %}">Ostatnie 30 dni</a> <div class="meeting-section">
<a href="{{ url_for('new_members', days=60) }}" class="filter-btn {% if days == 60 %}active{% endif %}">Ostatnie 60 dni</a> <h2>Posiedzenie Rady {{ item.meeting.meeting_number }}/{{ item.meeting.year }} — {{ item.meeting.meeting_date.strftime('%d.%m.%Y') }}</h2>
<a href="{{ url_for('new_members', days=90) }}" class="filter-btn {% if days == 90 %}active{% endif %}">Ostatnie 90 dni</a> <div class="members-grid">
<a href="{{ url_for('new_members', days=180) }}" class="filter-btn {% if days == 180 %}active{% endif %}">Ostatnie 6 miesiecy</a> {% for company in item.companies %}
</div>
<div class="members-grid">
{% if companies %}
{% for company in companies %}
<div class="member-card"> <div class="member-card">
{% if company.status == 'active' %}
<span class="new-badge">Nowy</span> <span class="new-badge">Nowy</span>
{% else %}
<span class="pending-badge">Profil w trakcie uzupełniania</span>
{% endif %}
{% if company.category %} {% if company.category %}
<div class="member-category">{{ company.category.name }}</div> <div class="member-category">{{ company.category.name }}</div>
{% endif %} {% endif %}
<div class="member-name"> <div class="member-name">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a> {% if company.status == 'active' %}
<a href="{{ url_for('public.company_detail_by_slug', slug=company.slug) }}">{{ company.name }}</a>
{% else %}
{{ company.name }}
{% endif %}
</div> </div>
{% if company.description_short %} {% if company.description_short %}
<div class="member-description"> <div class="member-description">
@ -189,23 +189,25 @@
</div> </div>
{% endif %} {% endif %}
<div class="member-meta"> <div class="member-meta">
<span class="member-date">Dolaczyl: {{ company.created_at|local_time('%d.%m.%Y') }}</span>
{% if company.address_city %}
<span class="member-location"> <span class="member-location">
{% if company.address_city %}
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path> <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle> <circle cx="12" cy="10" r="3"></circle>
</svg> </svg>
{{ company.address_city }} {{ company.address_city }}
{% endif %}
</span> </span>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} </div>
<div class="empty-state">
<p>Brak nowych firm w wybranym okresie</p>
</div>
{% endif %}
</div> </div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Brak informacji o nowych członkach</p>
</div>
{% endif %}
{% endblock %} {% endblock %}