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
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:
parent
3ccd7f837b
commit
90203676e5
@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
25
database.py
25
database.py
@ -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'
|
||||||
|
|||||||
18
database/migrations/099_add_admission_workflow_log.sql
Normal file
18
database/migrations/099_add_admission_workflow_log.sql
Normal 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;
|
||||||
395
services/admission_workflow.py
Normal file
395
services/admission_workflow.py
Normal 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)
|
||||||
215
templates/board/meeting_admissions.html
Normal file
215
templates/board/meeting_admissions.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user