feat: Add ZOPK (Zielony Okręg Przemysłowy Kaszubia) knowledge base

- Add database models for ZOPK projects, stakeholders, news, resources
- Add migration with initial data (5 projects, 7 stakeholders)
- Implement admin dashboard with news moderation workflow
- Add Brave Search API integration for automated news discovery
- Create public knowledge base pages (index, project detail, news list)
- Add navigation links in main menu and admin bar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 06:32:27 +01:00
parent e8714ac6b0
commit d51637a226
9 changed files with 3171 additions and 0 deletions

493
app.py
View File

@ -7176,6 +7176,499 @@ def release_notes():
return render_template('release_notes.html', releases=releases)
# ============================================================
# ZIELONY OKRĘG PRZEMYSŁOWY KASZUBIA (ZOPK)
# ============================================================
@app.route('/zopk')
def zopk_index():
"""
Public knowledge base page for ZOPK.
Shows projects, stakeholders, approved news, and resources.
"""
from database import ZOPKProject, ZOPKStakeholder, ZOPKNews, ZOPKResource
db = SessionLocal()
try:
# Get active projects
projects = db.query(ZOPKProject).filter(
ZOPKProject.is_active == True
).order_by(ZOPKProject.sort_order, ZOPKProject.name).all()
# Get active stakeholders
stakeholders = db.query(ZOPKStakeholder).filter(
ZOPKStakeholder.is_active == True
).order_by(ZOPKStakeholder.importance.desc(), ZOPKStakeholder.name).limit(10).all()
# Get approved news
news_items = db.query(ZOPKNews).filter(
ZOPKNews.status == 'approved'
).order_by(ZOPKNews.published_at.desc()).limit(10).all()
# Get featured resources
resources = db.query(ZOPKResource).filter(
ZOPKResource.status == 'approved'
).order_by(ZOPKResource.sort_order, ZOPKResource.created_at.desc()).limit(12).all()
# Stats
stats = {
'total_projects': len(projects),
'total_news': db.query(ZOPKNews).filter(ZOPKNews.status == 'approved').count(),
'total_resources': db.query(ZOPKResource).filter(ZOPKResource.status == 'approved').count(),
'total_stakeholders': db.query(ZOPKStakeholder).filter(ZOPKStakeholder.is_active == True).count()
}
return render_template('zopk/index.html',
projects=projects,
stakeholders=stakeholders,
news_items=news_items,
resources=resources,
stats=stats
)
finally:
db.close()
@app.route('/zopk/projekty/<slug>')
def zopk_project_detail(slug):
"""Project detail page"""
from database import ZOPKProject, ZOPKNews, ZOPKResource, ZOPKCompanyLink
db = SessionLocal()
try:
project = db.query(ZOPKProject).filter(ZOPKProject.slug == slug).first()
if not project:
abort(404)
# Get news for this project
news_items = db.query(ZOPKNews).filter(
ZOPKNews.project_id == project.id,
ZOPKNews.status == 'approved'
).order_by(ZOPKNews.published_at.desc()).limit(10).all()
# Get resources for this project
resources = db.query(ZOPKResource).filter(
ZOPKResource.project_id == project.id,
ZOPKResource.status == 'approved'
).order_by(ZOPKResource.sort_order).all()
# Get Norda companies linked to this project
company_links = db.query(ZOPKCompanyLink).filter(
ZOPKCompanyLink.project_id == project.id
).order_by(ZOPKCompanyLink.relevance_score.desc()).all()
return render_template('zopk/project_detail.html',
project=project,
news_items=news_items,
resources=resources,
company_links=company_links
)
finally:
db.close()
@app.route('/zopk/aktualnosci')
def zopk_news_list():
"""All ZOPK news - paginated"""
from database import ZOPKProject, ZOPKNews
db = SessionLocal()
try:
page = request.args.get('page', 1, type=int)
per_page = 20
project_slug = request.args.get('projekt')
query = db.query(ZOPKNews).filter(ZOPKNews.status == 'approved')
if project_slug:
project = db.query(ZOPKProject).filter(ZOPKProject.slug == project_slug).first()
if project:
query = query.filter(ZOPKNews.project_id == project.id)
total = query.count()
news_items = query.order_by(ZOPKNews.published_at.desc()).offset(
(page - 1) * per_page
).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
# Get projects for filter
projects = db.query(ZOPKProject).filter(
ZOPKProject.is_active == True
).order_by(ZOPKProject.sort_order).all()
return render_template('zopk/news_list.html',
news_items=news_items,
projects=projects,
current_project=project_slug,
page=page,
total_pages=total_pages,
total=total
)
finally:
db.close()
@app.route('/admin/zopk')
@login_required
def admin_zopk():
"""Admin dashboard for ZOPK management"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
from database import ZOPKProject, ZOPKStakeholder, ZOPKNews, ZOPKResource, ZOPKNewsFetchJob
db = SessionLocal()
try:
# Stats
stats = {
'total_projects': db.query(ZOPKProject).count(),
'total_stakeholders': db.query(ZOPKStakeholder).count(),
'total_news': db.query(ZOPKNews).count(),
'pending_news': db.query(ZOPKNews).filter(ZOPKNews.status == 'pending').count(),
'approved_news': db.query(ZOPKNews).filter(ZOPKNews.status == 'approved').count(),
'total_resources': db.query(ZOPKResource).count()
}
# Recent pending news
pending_news = db.query(ZOPKNews).filter(
ZOPKNews.status == 'pending'
).order_by(ZOPKNews.created_at.desc()).limit(10).all()
# All projects
projects = db.query(ZOPKProject).order_by(ZOPKProject.sort_order).all()
# Recent fetch jobs
fetch_jobs = db.query(ZOPKNewsFetchJob).order_by(
ZOPKNewsFetchJob.created_at.desc()
).limit(5).all()
return render_template('admin/zopk_dashboard.html',
stats=stats,
pending_news=pending_news,
projects=projects,
fetch_jobs=fetch_jobs
)
finally:
db.close()
@app.route('/admin/zopk/news')
@login_required
def admin_zopk_news():
"""Admin news management for ZOPK"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
from database import ZOPKProject, ZOPKNews
db = SessionLocal()
try:
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
per_page = 50
query = db.query(ZOPKNews)
if status != 'all':
query = query.filter(ZOPKNews.status == status)
total = query.count()
news_items = query.order_by(ZOPKNews.created_at.desc()).offset(
(page - 1) * per_page
).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
projects = db.query(ZOPKProject).order_by(ZOPKProject.sort_order).all()
return render_template('admin/zopk_news.html',
news_items=news_items,
projects=projects,
page=page,
total_pages=total_pages,
total=total,
current_status=status
)
finally:
db.close()
@app.route('/admin/zopk/news/<int:news_id>/approve', methods=['POST'])
@login_required
def admin_zopk_news_approve(news_id):
"""Approve a ZOPK news item"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from database import ZOPKNews
db = SessionLocal()
try:
news = db.query(ZOPKNews).filter(ZOPKNews.id == news_id).first()
if not news:
return jsonify({'success': False, 'error': 'Nie znaleziono newsa'}), 404
news.status = 'approved'
news.moderated_by = current_user.id
news.moderated_at = datetime.now()
db.commit()
return jsonify({'success': True, 'message': 'News został zatwierdzony'})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@app.route('/admin/zopk/news/<int:news_id>/reject', methods=['POST'])
@login_required
def admin_zopk_news_reject(news_id):
"""Reject a ZOPK news item"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from database import ZOPKNews
db = SessionLocal()
try:
data = request.get_json() or {}
reason = data.get('reason', '')
news = db.query(ZOPKNews).filter(ZOPKNews.id == news_id).first()
if not news:
return jsonify({'success': False, 'error': 'Nie znaleziono newsa'}), 404
news.status = 'rejected'
news.moderated_by = current_user.id
news.moderated_at = datetime.now()
news.rejection_reason = reason
db.commit()
return jsonify({'success': True, 'message': 'News został odrzucony'})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@app.route('/admin/zopk/news/add', methods=['POST'])
@login_required
def admin_zopk_news_add():
"""Manually add a ZOPK news item"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from database import ZOPKNews
import hashlib
db = SessionLocal()
try:
data = request.get_json() or {}
title = data.get('title', '').strip()
url = data.get('url', '').strip()
description = data.get('description', '').strip()
source_name = data.get('source_name', '').strip()
project_id = data.get('project_id')
if not title or not url:
return jsonify({'success': False, 'error': 'Tytuł i URL są wymagane'}), 400
# Generate URL hash for deduplication
url_hash = hashlib.sha256(url.encode()).hexdigest()
# Check if URL already exists
existing = db.query(ZOPKNews).filter(ZOPKNews.url_hash == url_hash).first()
if existing:
return jsonify({'success': False, 'error': 'Ten artykuł już istnieje w bazie'}), 400
# Extract domain from URL
from urllib.parse import urlparse
parsed = urlparse(url)
source_domain = parsed.netloc.replace('www.', '')
news = ZOPKNews(
title=title,
url=url,
url_hash=url_hash,
description=description,
source_name=source_name or source_domain,
source_domain=source_domain,
source_type='manual',
status='approved', # Manual entries are auto-approved
moderated_by=current_user.id,
moderated_at=datetime.now(),
published_at=datetime.now(),
project_id=project_id if project_id else None
)
db.add(news)
db.commit()
return jsonify({
'success': True,
'message': 'News został dodany',
'news_id': news.id
})
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@app.route('/api/zopk/search-news', methods=['POST'])
@login_required
def api_zopk_search_news():
"""
Search for ZOPK news using Brave Search API.
Admin only - triggers manual search.
"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from database import ZOPKNews, ZOPKNewsFetchJob
import hashlib
import uuid
db = SessionLocal()
try:
data = request.get_json() or {}
query = data.get('query', 'Zielony Okręg Przemysłowy Kaszubia')
# Check for Brave API key
brave_api_key = os.getenv('BRAVE_SEARCH_API_KEY')
if not brave_api_key:
return jsonify({
'success': False,
'error': 'Brak klucza API Brave Search. Ustaw BRAVE_SEARCH_API_KEY w .env'
}), 500
# Create fetch job record
job_id = str(uuid.uuid4())[:8]
fetch_job = ZOPKNewsFetchJob(
job_id=job_id,
search_query=query,
search_api='brave',
triggered_by='admin',
triggered_by_user=current_user.id,
status='running',
started_at=datetime.now()
)
db.add(fetch_job)
db.commit()
# Call Brave Search API
import requests
headers = {
'Accept': 'application/json',
'X-Subscription-Token': brave_api_key
}
params = {
'q': query,
'count': 20,
'freshness': 'pm', # past month
'country': 'pl',
'search_lang': 'pl'
}
response = requests.get(
'https://api.search.brave.com/res/v1/news/search',
headers=headers,
params=params,
timeout=30
)
if response.status_code != 200:
fetch_job.status = 'failed'
fetch_job.error_message = f'Brave API error: {response.status_code}'
fetch_job.completed_at = datetime.now()
db.commit()
return jsonify({
'success': False,
'error': f'Błąd API Brave: {response.status_code}'
}), 500
results = response.json().get('results', [])
fetch_job.results_found = len(results)
# Process results
new_count = 0
for item in results:
url = item.get('url', '')
if not url:
continue
url_hash = hashlib.sha256(url.encode()).hexdigest()
# Skip if already exists
existing = db.query(ZOPKNews).filter(ZOPKNews.url_hash == url_hash).first()
if existing:
continue
# Extract domain
from urllib.parse import urlparse
parsed = urlparse(url)
source_domain = parsed.netloc.replace('www.', '')
# Parse date
published_at = None
age = item.get('age', '')
# Age format: "2 days ago", "5 hours ago", etc.
# For now, just use current time minus rough estimate
news = ZOPKNews(
title=item.get('title', 'Bez tytułu'),
url=url,
url_hash=url_hash,
description=item.get('description', ''),
source_name=item.get('source', source_domain),
source_domain=source_domain,
image_url=item.get('thumbnail', {}).get('src'),
source_type='brave_search',
fetch_job_id=job_id,
status='pending', # Requires moderation
published_at=datetime.now() # Would need proper date parsing
)
db.add(news)
new_count += 1
fetch_job.results_new = new_count
fetch_job.status = 'completed'
fetch_job.completed_at = datetime.now()
db.commit()
return jsonify({
'success': True,
'message': f'Znaleziono {len(results)} wyników, dodano {new_count} nowych',
'job_id': job_id,
'found': len(results),
'new': new_count
})
except Exception as e:
db.rollback()
logger.error(f"ZOPK news search error: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# ERROR HANDLERS
# ============================================================

View File

@ -1634,6 +1634,299 @@ class MembershipFeeConfig(Base):
company = relationship('Company')
# ============================================================
# ZIELONY OKRĘG PRZEMYSŁOWY KASZUBIA (ZOPK)
# ============================================================
class ZOPKProject(Base):
"""
Sub-projects within ZOPK initiative.
Examples: offshore wind, nuclear plant, data centers, hydrogen labs
"""
__tablename__ = 'zopk_projects'
id = Column(Integer, primary_key=True)
slug = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
description = Column(Text)
# Project details
project_type = Column(String(50)) # energy, infrastructure, technology, defense
status = Column(String(50), default='planned') # planned, in_progress, completed
start_date = Column(Date)
end_date = Column(Date)
# Location info
location = Column(String(255))
region = Column(String(100)) # Wejherowo, Rumia, Gdynia, etc.
# Key metrics
estimated_investment = Column(Numeric(15, 2)) # Investment amount in PLN
estimated_jobs = Column(Integer) # Number of jobs created
# Visual
icon = Column(String(50)) # CSS icon class or emoji
color = Column(String(20)) # HEX color for badges
# Display order
sort_order = Column(Integer, default=0)
is_featured = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class ZOPKStakeholder(Base):
"""
Key people and organizations involved in ZOPK.
Politicians, coordinators, companies, institutions.
"""
__tablename__ = 'zopk_stakeholders'
id = Column(Integer, primary_key=True)
# Person or organization
stakeholder_type = Column(String(20), nullable=False) # person, organization
name = Column(String(255), nullable=False)
# Role and affiliation
role = Column(String(255)) # Koordynator, Minister, Starosta, etc.
organization = Column(String(255)) # MON, Starostwo Wejherowskie, etc.
# Contact (optional, public info only)
email = Column(String(255))
phone = Column(String(50))
website = Column(String(500))
# Social media
linkedin_url = Column(String(500))
twitter_url = Column(String(500))
# Photo/logo
photo_url = Column(String(500))
# Description
bio = Column(Text)
# Categorization
category = Column(String(50)) # government, local_authority, business, academic
importance = Column(Integer, default=0) # For sorting (higher = more important)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project_links = relationship('ZOPKStakeholderProject', back_populates='stakeholder')
class ZOPKStakeholderProject(Base):
"""Link table between stakeholders and projects"""
__tablename__ = 'zopk_stakeholder_projects'
id = Column(Integer, primary_key=True)
stakeholder_id = Column(Integer, ForeignKey('zopk_stakeholders.id', ondelete='CASCADE'), nullable=False)
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='CASCADE'), nullable=False)
role_in_project = Column(String(255)) # e.g., "Koordynator", "Inwestor"
created_at = Column(DateTime, default=datetime.now)
stakeholder = relationship('ZOPKStakeholder', back_populates='project_links')
project = relationship('ZOPKProject', backref='stakeholder_links')
__table_args__ = (
UniqueConstraint('stakeholder_id', 'project_id', name='uq_stakeholder_project'),
)
class ZOPKNews(Base):
"""
News articles about ZOPK with approval workflow.
Can be fetched automatically or added manually.
"""
__tablename__ = 'zopk_news'
id = Column(Integer, primary_key=True)
# Source information
title = Column(String(500), nullable=False)
description = Column(Text)
url = Column(String(1000), nullable=False)
source_name = Column(String(200)) # Portal name: trojmiasto.pl, etc.
source_domain = Column(String(200)) # Domain: trojmiasto.pl
# Article details
published_at = Column(DateTime)
author = Column(String(255))
image_url = Column(String(1000))
# Categorization
news_type = Column(String(50)) # news, announcement, interview, press_release
project_id = Column(Integer, ForeignKey('zopk_projects.id')) # Link to sub-project
# AI Analysis
relevance_score = Column(Numeric(3, 2)) # 0.00-1.00
sentiment = Column(String(20)) # positive, neutral, negative
ai_summary = Column(Text) # AI-generated summary
keywords = Column(StringArray) # Extracted keywords
# Moderation workflow
status = Column(String(20), default='pending', index=True) # pending, approved, rejected
moderated_by = Column(Integer, ForeignKey('users.id'))
moderated_at = Column(DateTime)
rejection_reason = Column(Text)
# Source tracking
source_type = Column(String(50), default='manual') # manual, brave_search, rss, scraper
fetch_job_id = Column(String(100)) # ID of the fetch job that found this
# Deduplication
url_hash = Column(String(64), unique=True, index=True) # SHA256 of URL
is_featured = Column(Boolean, default=False)
views_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project = relationship('ZOPKProject', backref='news_items')
moderator = relationship('User', foreign_keys=[moderated_by])
class ZOPKResource(Base):
"""
Resources: documents, links, images, videos related to ZOPK.
Knowledge base materials.
"""
__tablename__ = 'zopk_resources'
id = Column(Integer, primary_key=True)
# Resource identification
title = Column(String(255), nullable=False)
description = Column(Text)
# Resource type
resource_type = Column(String(50), nullable=False) # link, document, image, video, map
# URL or file path
url = Column(String(1000))
file_path = Column(String(500)) # For uploaded files
file_size = Column(Integer)
mime_type = Column(String(100))
# Thumbnail
thumbnail_url = Column(String(1000))
# Categorization
category = Column(String(50)) # official, media, research, presentation
project_id = Column(Integer, ForeignKey('zopk_projects.id'))
# Tags for search
tags = Column(StringArray)
# Source
source_name = Column(String(255))
source_date = Column(Date)
# Moderation
status = Column(String(20), default='approved', index=True) # pending, approved, rejected
uploaded_by = Column(Integer, ForeignKey('users.id'))
is_featured = Column(Boolean, default=False)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project = relationship('ZOPKProject', backref='resources')
uploader = relationship('User', foreign_keys=[uploaded_by])
class ZOPKCompanyLink(Base):
"""
Links between ZOPK projects and Norda Biznes member companies.
Shows which local companies can benefit or collaborate.
"""
__tablename__ = 'zopk_company_links'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='CASCADE'), nullable=False)
# Type of involvement
link_type = Column(String(50), nullable=False) # potential_supplier, partner, investor, beneficiary
# Description of potential collaboration
collaboration_description = Column(Text)
# Relevance scoring
relevance_score = Column(Integer) # 1-100
# Status
status = Column(String(20), default='suggested') # suggested, confirmed, active, completed
# Admin notes
admin_notes = Column(Text)
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', backref='zopk_project_links')
project = relationship('ZOPKProject', backref='company_links')
creator = relationship('User', foreign_keys=[created_by])
__table_args__ = (
UniqueConstraint('company_id', 'project_id', 'link_type', name='uq_company_project_link'),
)
class ZOPKNewsFetchJob(Base):
"""
Tracking for automated news fetch jobs.
Records when and what was searched.
"""
__tablename__ = 'zopk_news_fetch_jobs'
id = Column(Integer, primary_key=True)
job_id = Column(String(100), unique=True, nullable=False, index=True)
# Job configuration
search_query = Column(String(500))
search_api = Column(String(50)) # brave, google, bing
date_range_start = Column(Date)
date_range_end = Column(Date)
# Results
results_found = Column(Integer, default=0)
results_new = Column(Integer, default=0) # New (not duplicates)
results_approved = Column(Integer, default=0)
# Status
status = Column(String(20), default='pending') # pending, running, completed, failed
error_message = Column(Text)
# Timing
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Trigger
triggered_by = Column(String(50)) # cron, manual, admin
triggered_by_user = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
# Relationships
user = relationship('User', foreign_keys=[triggered_by_user])
# ============================================================
# DATABASE INITIALIZATION
# ============================================================

View File

@ -0,0 +1,351 @@
-- ============================================================
-- NordaBiz - Migration 005: ZOPK Knowledge Base
-- ============================================================
-- Created: 2026-01-11
-- Description:
-- Zielony Okręg Przemysłowy Kaszubia (ZOPK) - Knowledge Base
-- Tables for projects, stakeholders, news, resources, and company links
-- ============================================================
-- ============================================================
-- 1. ZOPK PROJECTS (sub-initiatives)
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_projects (
id SERIAL PRIMARY KEY,
slug VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Project details
project_type VARCHAR(50), -- energy, infrastructure, technology, defense
status VARCHAR(50) DEFAULT 'planned', -- planned, in_progress, completed
start_date DATE,
end_date DATE,
-- Location
location VARCHAR(255),
region VARCHAR(100), -- Wejherowo, Rumia, Gdynia
-- Key metrics
estimated_investment NUMERIC(15, 2), -- PLN
estimated_jobs INTEGER,
-- Visual
icon VARCHAR(50), -- CSS icon or emoji
color VARCHAR(20), -- HEX color
-- Display
sort_order INTEGER DEFAULT 0,
is_featured BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_zopk_projects_slug ON zopk_projects(slug);
CREATE INDEX IF NOT EXISTS idx_zopk_projects_status ON zopk_projects(status);
CREATE INDEX IF NOT EXISTS idx_zopk_projects_type ON zopk_projects(project_type);
COMMENT ON TABLE zopk_projects IS 'Sub-projects within ZOPK initiative (offshore, nuclear, data centers, etc.)';
-- ============================================================
-- 2. ZOPK STAKEHOLDERS (people and organizations)
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_stakeholders (
id SERIAL PRIMARY KEY,
stakeholder_type VARCHAR(20) NOT NULL, -- person, organization
name VARCHAR(255) NOT NULL,
-- Role and affiliation
role VARCHAR(255), -- Koordynator, Minister, Starosta
organization VARCHAR(255), -- MON, Starostwo Wejherowskie
-- Contact (public info)
email VARCHAR(255),
phone VARCHAR(50),
website VARCHAR(500),
-- Social media
linkedin_url VARCHAR(500),
twitter_url VARCHAR(500),
-- Visual
photo_url VARCHAR(500),
-- Description
bio TEXT,
-- Categorization
category VARCHAR(50), -- government, local_authority, business, academic
importance INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_zopk_stakeholders_type ON zopk_stakeholders(stakeholder_type);
CREATE INDEX IF NOT EXISTS idx_zopk_stakeholders_category ON zopk_stakeholders(category);
COMMENT ON TABLE zopk_stakeholders IS 'Key people and organizations involved in ZOPK';
-- ============================================================
-- 3. STAKEHOLDER-PROJECT LINKS
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_stakeholder_projects (
id SERIAL PRIMARY KEY,
stakeholder_id INTEGER NOT NULL REFERENCES zopk_stakeholders(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES zopk_projects(id) ON DELETE CASCADE,
role_in_project VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_stakeholder_project UNIQUE (stakeholder_id, project_id)
);
CREATE INDEX IF NOT EXISTS idx_zopk_sp_stakeholder ON zopk_stakeholder_projects(stakeholder_id);
CREATE INDEX IF NOT EXISTS idx_zopk_sp_project ON zopk_stakeholder_projects(project_id);
COMMENT ON TABLE zopk_stakeholder_projects IS 'Link table: stakeholders to projects';
-- ============================================================
-- 4. ZOPK NEWS (with approval workflow)
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_news (
id SERIAL PRIMARY KEY,
-- Source information
title VARCHAR(500) NOT NULL,
description TEXT,
url VARCHAR(1000) NOT NULL,
source_name VARCHAR(200), -- trojmiasto.pl, etc.
source_domain VARCHAR(200),
-- Article details
published_at TIMESTAMP,
author VARCHAR(255),
image_url VARCHAR(1000),
-- Categorization
news_type VARCHAR(50), -- news, announcement, interview, press_release
project_id INTEGER REFERENCES zopk_projects(id),
-- AI Analysis
relevance_score NUMERIC(3, 2), -- 0.00-1.00
sentiment VARCHAR(20), -- positive, neutral, negative
ai_summary TEXT,
keywords TEXT[], -- Extracted keywords array
-- Moderation workflow
status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected
moderated_by INTEGER REFERENCES users(id),
moderated_at TIMESTAMP,
rejection_reason TEXT,
-- Source tracking
source_type VARCHAR(50) DEFAULT 'manual', -- manual, brave_search, rss
fetch_job_id VARCHAR(100),
-- Deduplication
url_hash VARCHAR(64) UNIQUE,
is_featured BOOLEAN DEFAULT FALSE,
views_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_zopk_news_status ON zopk_news(status);
CREATE INDEX IF NOT EXISTS idx_zopk_news_project ON zopk_news(project_id);
CREATE INDEX IF NOT EXISTS idx_zopk_news_published ON zopk_news(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_zopk_news_type ON zopk_news(news_type);
CREATE INDEX IF NOT EXISTS idx_zopk_news_url_hash ON zopk_news(url_hash);
COMMENT ON TABLE zopk_news IS 'News articles about ZOPK with approval workflow';
-- ============================================================
-- 5. ZOPK RESOURCES (documents, links, media)
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_resources (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
-- Resource type
resource_type VARCHAR(50) NOT NULL, -- link, document, image, video, map
-- URL or file
url VARCHAR(1000),
file_path VARCHAR(500),
file_size INTEGER,
mime_type VARCHAR(100),
-- Thumbnail
thumbnail_url VARCHAR(1000),
-- Categorization
category VARCHAR(50), -- official, media, research, presentation
project_id INTEGER REFERENCES zopk_projects(id),
-- Tags
tags TEXT[],
-- Source
source_name VARCHAR(255),
source_date DATE,
-- Moderation
status VARCHAR(20) DEFAULT 'approved',
uploaded_by INTEGER REFERENCES users(id),
is_featured BOOLEAN DEFAULT FALSE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_zopk_resources_type ON zopk_resources(resource_type);
CREATE INDEX IF NOT EXISTS idx_zopk_resources_project ON zopk_resources(project_id);
CREATE INDEX IF NOT EXISTS idx_zopk_resources_category ON zopk_resources(category);
CREATE INDEX IF NOT EXISTS idx_zopk_resources_status ON zopk_resources(status);
COMMENT ON TABLE zopk_resources IS 'Resources: documents, links, images, videos for ZOPK knowledge base';
-- ============================================================
-- 6. ZOPK COMPANY LINKS (Norda members)
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_company_links (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES zopk_projects(id) ON DELETE CASCADE,
-- Type of involvement
link_type VARCHAR(50) NOT NULL, -- potential_supplier, partner, investor, beneficiary
-- Description
collaboration_description TEXT,
-- Scoring
relevance_score INTEGER, -- 1-100
-- Status
status VARCHAR(20) DEFAULT 'suggested', -- suggested, confirmed, active, completed
-- Admin notes
admin_notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_company_project_link UNIQUE (company_id, project_id, link_type)
);
CREATE INDEX IF NOT EXISTS idx_zopk_cl_company ON zopk_company_links(company_id);
CREATE INDEX IF NOT EXISTS idx_zopk_cl_project ON zopk_company_links(project_id);
CREATE INDEX IF NOT EXISTS idx_zopk_cl_status ON zopk_company_links(status);
COMMENT ON TABLE zopk_company_links IS 'Links between ZOPK projects and Norda Biznes member companies';
-- ============================================================
-- 7. ZOPK NEWS FETCH JOBS (automation tracking)
-- ============================================================
CREATE TABLE IF NOT EXISTS zopk_news_fetch_jobs (
id SERIAL PRIMARY KEY,
job_id VARCHAR(100) UNIQUE NOT NULL,
-- Config
search_query VARCHAR(500),
search_api VARCHAR(50), -- brave, google, bing
date_range_start DATE,
date_range_end DATE,
-- Results
results_found INTEGER DEFAULT 0,
results_new INTEGER DEFAULT 0,
results_approved INTEGER DEFAULT 0,
-- Status
status VARCHAR(20) DEFAULT 'pending', -- pending, running, completed, failed
error_message TEXT,
-- Timing
started_at TIMESTAMP,
completed_at TIMESTAMP,
-- Trigger
triggered_by VARCHAR(50), -- cron, manual, admin
triggered_by_user INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_zopk_fetch_status ON zopk_news_fetch_jobs(status);
CREATE INDEX IF NOT EXISTS idx_zopk_fetch_created ON zopk_news_fetch_jobs(created_at DESC);
COMMENT ON TABLE zopk_news_fetch_jobs IS 'Tracking for automated ZOPK news fetch jobs';
-- ============================================================
-- 8. INITIAL DATA - PROJECTS
-- ============================================================
INSERT INTO zopk_projects (slug, name, description, project_type, status, region, icon, color, sort_order, is_featured)
VALUES
('offshore-wind', 'Morska Energetyka Wiatrowa', 'Farmy wiatrowe na Baltyku - kluczowy element transformacji energetycznej regionu', 'energy', 'in_progress', 'Baltyk / Trojmiasto', 'wind', '#0891b2', 1, TRUE),
('nuclear-plant', 'Elektrownia Jadrowa', 'Budowa pierwszej polskiej elektrowni jadrowej w lokalizacji Lubiatowo-Kopalino', 'energy', 'planned', 'Choczewo', 'atom', '#7c3aed', 2, TRUE),
('data-centers', 'Centra Danych', 'Nowoczesne centra przetwarzania danych wykorzystujace czysta energie', 'technology', 'planned', 'Trojmiasto', 'server', '#2563eb', 3, FALSE),
('hydrogen-labs', 'Laboratoria Wodorowe', 'Badania i rozwoj technologii wodorowych dla przemyslu', 'technology', 'planned', 'Gdynia / Rumia', 'flask', '#059669', 4, FALSE),
('kongsberg', 'Inwestycja Kongsberg', 'Zaklad produkcyjny norweskiego koncernu zbrojeniowego Kongsberg', 'defense', 'in_progress', 'Rumia', 'shield', '#dc2626', 5, TRUE)
ON CONFLICT (slug) DO NOTHING;
-- ============================================================
-- 9. INITIAL DATA - STAKEHOLDERS
-- ============================================================
INSERT INTO zopk_stakeholders (stakeholder_type, name, role, organization, category, importance, is_active)
VALUES
('person', 'Maciej Samsonowicz', 'Koordynator projektu, Doradca MON', 'Ministerstwo Obrony Narodowej', 'government', 100, TRUE),
('person', 'Wladyslaw Kosiniak-Kamysz', 'Minister Obrony Narodowej', 'Ministerstwo Obrony Narodowej', 'government', 95, TRUE),
('person', 'Marcin Kaczmarek', 'Starosta Wejherowski', 'Starostwo Powiatowe w Wejherowie', 'local_authority', 80, TRUE),
('person', 'Michal Pasieczny', 'Burmistrz Rumi', 'Urzad Miasta Rumi', 'local_authority', 75, TRUE),
('organization', 'Ministerstwo Obrony Narodowej', 'Ministerstwo odpowiedzialne za obronnosc', NULL, 'government', 90, TRUE),
('organization', 'Kongsberg Defence & Aerospace', 'Norweski koncern zbrojeniowy', NULL, 'business', 85, TRUE),
('organization', 'Polskie Elektrownie Jadrowe', 'Spolka realizujaca program jadrowy', NULL, 'business', 85, TRUE)
ON CONFLICT DO NOTHING;
-- ============================================================
-- 10. PERMISSIONS
-- ============================================================
GRANT ALL ON TABLE zopk_projects TO nordabiz_app;
GRANT ALL ON TABLE zopk_stakeholders TO nordabiz_app;
GRANT ALL ON TABLE zopk_stakeholder_projects TO nordabiz_app;
GRANT ALL ON TABLE zopk_news TO nordabiz_app;
GRANT ALL ON TABLE zopk_resources TO nordabiz_app;
GRANT ALL ON TABLE zopk_company_links TO nordabiz_app;
GRANT ALL ON TABLE zopk_news_fetch_jobs TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_projects_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_stakeholders_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_stakeholder_projects_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_news_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_resources_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_company_links_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE zopk_news_fetch_jobs_id_seq TO nordabiz_app;
-- ============================================================
-- MIGRATION COMPLETE
-- ============================================================

View File

@ -0,0 +1,647 @@
{% extends "base.html" %}
{% block title %}ZOPK - Panel Admina - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.admin-actions {
display: flex;
gap: var(--spacing-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.stat-card.warning .stat-value {
color: var(--warning);
}
.stat-card.success .stat-value {
color: var(--success);
}
.panel-section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.panel-section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.pending-news-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pending-news-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
gap: var(--spacing-md);
}
.news-info {
flex: 1;
min-width: 0;
}
.news-info h4 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
word-break: break-word;
}
.news-info h4 a {
color: inherit;
text-decoration: none;
}
.news-info h4 a:hover {
color: var(--primary);
}
.news-meta {
font-size: var(--font-size-xs);
color: var(--text-secondary);
display: flex;
gap: var(--spacing-md);
}
.news-actions {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.btn-approve {
background: var(--success);
color: white;
}
.btn-approve:hover {
background: #059669;
}
.btn-reject {
background: var(--danger);
color: white;
}
.btn-reject:hover {
background: #dc2626;
}
.projects-table {
width: 100%;
border-collapse: collapse;
}
.projects-table th,
.projects-table td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.projects-table th {
font-weight: 600;
color: var(--text-secondary);
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: 500;
}
.status-planned { background: #fef3c7; color: #92400e; }
.status-in_progress { background: #dbeafe; color: #1e40af; }
.status-completed { background: #dcfce7; color: #166534; }
.search-section {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
color: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-xl);
}
.search-section h3 {
margin-bottom: var(--spacing-md);
}
.search-form {
display: flex;
gap: var(--spacing-sm);
}
.search-form input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.search-form button {
padding: var(--spacing-sm) var(--spacing-lg);
background: white;
color: var(--primary);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.search-form button:hover {
background: #f0fdf4;
}
.search-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.fetch-jobs-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.fetch-job {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.fetch-job-status {
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.fetch-job-status.completed { background: #dcfce7; color: #166534; }
.fetch-job-status.failed { background: #fee2e2; color: #991b1b; }
.fetch-job-status.running { background: #dbeafe; color: #1e40af; }
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.pending-news-item {
flex-direction: column;
}
.news-actions {
width: 100%;
justify-content: flex-end;
}
.search-form {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Zielony Okręg Przemysłowy Kaszubia</h1>
<p class="text-muted">Panel zarządzania bazą wiedzy</p>
</div>
<div class="admin-actions">
<a href="{{ url_for('zopk_index') }}" class="btn btn-secondary" target="_blank">Zobacz stronę publiczną</a>
<a href="{{ url_for('admin_zopk_news') }}" class="btn btn-secondary">Zarządzaj newsami</a>
<button class="btn btn-primary" onclick="openAddNewsModal()">+ Dodaj news</button>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_projects }}</div>
<div class="stat-label">Projektów</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_stakeholders }}</div>
<div class="stat-label">Interesariuszy</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ stats.pending_news }}</div>
<div class="stat-label">Oczekujących newsów</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ stats.approved_news }}</div>
<div class="stat-label">Zatwierdzonych newsów</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_resources }}</div>
<div class="stat-label">Materiałów</div>
</div>
</div>
<!-- Search Section -->
<div class="search-section">
<h3>Wyszukaj nowe artykuły</h3>
<p style="opacity: 0.9; margin-bottom: var(--spacing-md); font-size: var(--font-size-sm);">Użyj Brave Search API do automatycznego wyszukania nowych artykułów o ZOPK</p>
<div class="search-form">
<input type="text" id="searchQuery" value="Zielony Okręg Przemysłowy Kaszubia" placeholder="Wpisz zapytanie...">
<button type="button" onclick="searchNews()" id="searchBtn">Szukaj artykułów</button>
</div>
<div id="searchResult" style="margin-top: var(--spacing-md); display: none;"></div>
</div>
<!-- Pending News -->
<div class="panel-section">
<h2>Newsy oczekujące na moderację ({{ stats.pending_news }})</h2>
{% if pending_news %}
<div class="pending-news-list">
{% for news in pending_news %}
<div class="pending-news-item" id="news-{{ news.id }}">
<div class="news-info">
<h4><a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a></h4>
<div class="news-meta">
<span>{{ news.source_name or news.source_domain }}</span>
<span>{{ news.created_at.strftime('%d.%m.%Y %H:%M') if news.created_at else '-' }}</span>
<span>{{ news.source_type }}</span>
</div>
</div>
<div class="news-actions">
<button class="btn btn-sm btn-approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="btn btn-sm btn-reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak newsów oczekujących na moderację.</p>
</div>
{% endif %}
</div>
<!-- Projects -->
<div class="panel-section">
<h2>Projekty strategiczne</h2>
{% if projects %}
<table class="projects-table">
<thead>
<tr>
<th>Nazwa</th>
<th>Typ</th>
<th>Status</th>
<th>Region</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<strong>{{ project.name }}</strong>
<br><small class="text-muted">{{ project.slug }}</small>
</td>
<td>{{ project.project_type or '-' }}</td>
<td>
<span class="status-badge status-{{ project.status }}">
{% if project.status == 'planned' %}Planowany{% elif project.status == 'in_progress' %}W realizacji{% else %}{{ project.status }}{% endif %}
</span>
</td>
<td>{{ project.region or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak projektów.</p>
</div>
{% endif %}
</div>
<!-- Recent Fetch Jobs -->
{% if fetch_jobs %}
<div class="panel-section">
<h2>Ostatnie wyszukiwania</h2>
<div class="fetch-jobs-list">
{% for job in fetch_jobs %}
<div class="fetch-job">
<div>
<strong>{{ job.search_query }}</strong>
<br><small class="text-muted">{{ job.created_at.strftime('%d.%m.%Y %H:%M') if job.created_at else '-' }}</small>
</div>
<div>
Znaleziono: {{ job.results_found or 0 }} | Nowych: {{ job.results_new or 0 }}
</div>
<span class="fetch-job-status {{ job.status }}">{{ job.status }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Add News Modal -->
<div class="modal-overlay" id="addNewsModal">
<div class="modal">
<h3>Dodaj news ręcznie</h3>
<form id="addNewsForm">
<div class="form-group">
<label for="newsTitle">Tytuł *</label>
<input type="text" id="newsTitle" name="title" required>
</div>
<div class="form-group">
<label for="newsUrl">URL artykułu *</label>
<input type="url" id="newsUrl" name="url" required placeholder="https://...">
</div>
<div class="form-group">
<label for="newsDescription">Opis</label>
<textarea id="newsDescription" name="description" placeholder="Krótki opis artykułu..."></textarea>
</div>
<div class="form-group">
<label for="newsSource">Źródło</label>
<input type="text" id="newsSource" name="source_name" placeholder="np. trojmiasto.pl">
</div>
<div class="form-group">
<label for="newsProject">Projekt</label>
<select id="newsProject" name="project_id">
<option value="">-- Brak --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeAddNewsModal()">Anuluj</button>
<button type="submit" class="btn btn-primary">Dodaj</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
function openAddNewsModal() {
document.getElementById('addNewsModal').classList.add('active');
}
function closeAddNewsModal() {
document.getElementById('addNewsModal').classList.remove('active');
document.getElementById('addNewsForm').reset();
}
document.getElementById('addNewsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
title: document.getElementById('newsTitle').value,
url: document.getElementById('newsUrl').value,
description: document.getElementById('newsDescription').value,
source_name: document.getElementById('newsSource').value,
project_id: document.getElementById('newsProject').value || null
};
try {
const response = await fetch('{{ url_for("admin_zopk_news_add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
alert('News został dodany.');
closeAddNewsModal();
location.reload();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
});
async function approveNews(newsId) {
if (!confirm('Czy na pewno chcesz zatwierdzić ten news?')) return;
try {
const response = await fetch(`/admin/zopk/news/${newsId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
document.getElementById(`news-${newsId}`).remove();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
}
async function rejectNews(newsId) {
const reason = prompt('Powód odrzucenia (opcjonalnie):');
if (reason === null) return; // User cancelled
try {
const response = await fetch(`/admin/zopk/news/${newsId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
document.getElementById(`news-${newsId}`).remove();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
}
async function searchNews() {
const btn = document.getElementById('searchBtn');
const resultDiv = document.getElementById('searchResult');
const query = document.getElementById('searchQuery').value;
btn.disabled = true;
btn.textContent = 'Szukam...';
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<p>Trwa wyszukiwanie artykułów...</p>';
try {
const response = await fetch('{{ url_for("api_zopk_search_news") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ query: query })
});
const data = await response.json();
if (data.success) {
resultDiv.innerHTML = `<p style="color: #dcfce7;">${data.message}</p>`;
if (data.new > 0) {
setTimeout(() => location.reload(), 2000);
}
} else {
resultDiv.innerHTML = `<p style="color: #fca5a5;">Błąd: ${data.error}</p>`;
}
} catch (error) {
resultDiv.innerHTML = `<p style="color: #fca5a5;">Błąd połączenia: ${error.message}</p>`;
} finally {
btn.disabled = false;
btn.textContent = 'Szukaj artykułów';
}
}
// Close modal on click outside
document.getElementById('addNewsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeAddNewsModal();
}
});
{% endblock %}

View File

@ -0,0 +1,322 @@
{% extends "base.html" %}
{% block title %}ZOPK Newsy - Panel Admina{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.page-header h1 {
font-size: var(--font-size-2xl);
}
.filters {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
}
.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);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.filter-btn:hover {
background: var(--background);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.news-table {
width: 100%;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.news-table th,
.news-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.news-table th {
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.news-table tr:hover {
background: var(--background);
}
.news-title {
max-width: 400px;
}
.news-title a {
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
}
.news-title a:hover {
color: var(--primary);
}
.news-title small {
display: block;
color: var(--text-secondary);
font-size: var(--font-size-xs);
margin-top: 2px;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.source-badge {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
background: #f3f4f6;
color: #374151;
}
.action-btn {
padding: 4px 8px;
font-size: var(--font-size-xs);
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: var(--transition);
}
.action-btn.approve {
background: var(--success);
color: white;
}
.action-btn.reject {
background: var(--danger);
color: white;
}
.action-btn:hover {
opacity: 0.8;
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
}
.pagination a {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--background);
}
.pagination span.current {
background: var(--primary);
color: white;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
background: var(--surface);
border-radius: var(--radius-lg);
}
@media (max-width: 768px) {
.news-table {
display: block;
overflow-x: auto;
}
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Zarządzanie newsami ZOPK</h1>
<p class="text-muted">{{ total }} artykułów</p>
</div>
<a href="{{ url_for('admin_zopk') }}" class="btn btn-secondary">Powrót do dashboardu</a>
</div>
<div class="filters">
<span class="text-muted">Status:</span>
<a href="{{ url_for('admin_zopk_news', status='all') }}" class="filter-btn {% if current_status == 'all' %}active{% endif %}">Wszystkie</a>
<a href="{{ url_for('admin_zopk_news', status='pending') }}" class="filter-btn {% if current_status == 'pending' %}active{% endif %}">Oczekujące</a>
<a href="{{ url_for('admin_zopk_news', status='approved') }}" class="filter-btn {% if current_status == 'approved' %}active{% endif %}">Zatwierdzone</a>
<a href="{{ url_for('admin_zopk_news', status='rejected') }}" class="filter-btn {% if current_status == 'rejected' %}active{% endif %}">Odrzucone</a>
</div>
{% if news_items %}
<table class="news-table">
<thead>
<tr>
<th style="width: 40%">Tytuł</th>
<th>Źródło</th>
<th>Typ</th>
<th>Status</th>
<th>Data</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for news in news_items %}
<tr id="news-row-{{ news.id }}">
<td class="news-title">
<a href="{{ news.url }}" target="_blank" rel="noopener">{{ news.title }}</a>
<small>{{ news.source_domain }}</small>
</td>
<td>{{ news.source_name or news.source_domain }}</td>
<td><span class="source-badge">{{ news.source_type }}</span></td>
<td>
<span class="status-badge status-{{ news.status }}">
{% if news.status == 'pending' %}Oczekuje{% elif news.status == 'approved' %}Zatwierdzony{% else %}Odrzucony{% endif %}
</span>
</td>
<td>{{ news.created_at.strftime('%d.%m.%Y') if news.created_at else '-' }}</td>
<td>
{% if news.status == 'pending' %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Zatwierdź</button>
<button class="action-btn reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% elif news.status == 'approved' %}
<button class="action-btn reject" onclick="rejectNews({{ news.id }})">Odrzuć</button>
{% else %}
<button class="action-btn approve" onclick="approveNews({{ news.id }})">Przywróć</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for('admin_zopk_news', page=page-1, status=current_status) }}">&laquo; Poprzednia</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('admin_zopk_news', page=p, status=current_status) }}">{{ p }}</a>
{% elif p == 4 or p == total_pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('admin_zopk_news', page=page+1, status=current_status) }}">Następna &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak artykułów o tym statusie.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
async function approveNews(newsId) {
try {
const response = await fetch(`/admin/zopk/news/${newsId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
}
async function rejectNews(newsId) {
const reason = prompt('Powód odrzucenia (opcjonalnie):');
if (reason === null) return;
try {
const response = await fetch(`/admin/zopk/news/${newsId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ reason: reason })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert(data.error || 'Wystąpił błąd');
}
} catch (error) {
alert('Błąd połączenia: ' + error.message);
}
}
{% endblock %}

View File

@ -923,6 +923,7 @@
<li><a href="{{ url_for('forum_index') }}">Forum</a></li>
<li><a href="{{ url_for('classifieds_index') }}">Tablica B2B</a></li>
<li><a href="{{ url_for('chat') }}">Chat AI</a></li>
<li><a href="{{ url_for('zopk_index') }}">ZOPK</a></li>
</ul>
</li>
@ -1046,6 +1047,12 @@
</svg>
Social Media
</a>
<a href="{{ url_for('admin_zopk') }}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
ZOPK
</a>
</div>
</div>

489
templates/zopk/index.html Normal file
View File

@ -0,0 +1,489 @@
{% extends "base.html" %}
{% block title %}Zielony Okręg Przemysłowy Kaszubia - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.zopk-hero {
background: linear-gradient(135deg, #059669 0%, #047857 50%, #065f46 100%);
color: white;
padding: var(--spacing-2xl) 0;
margin-bottom: var(--spacing-xl);
border-radius: var(--radius-lg);
}
.zopk-hero h1 {
font-size: var(--font-size-3xl);
margin-bottom: var(--spacing-md);
}
.zopk-hero p {
font-size: var(--font-size-lg);
opacity: 0.9;
max-width: 800px;
}
.zopk-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--spacing-lg);
margin-top: var(--spacing-xl);
}
.zopk-stat {
text-align: center;
padding: var(--spacing-md);
background: rgba(255,255,255,0.1);
border-radius: var(--radius);
}
.zopk-stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
}
.zopk-stat-label {
font-size: var(--font-size-sm);
opacity: 0.8;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.section-header h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
}
/* Projects Grid */
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.project-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
transition: var(--transition);
text-decoration: none;
color: inherit;
display: block;
border-left: 4px solid var(--primary);
}
.project-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.project-icon {
width: 48px;
height: 48px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: var(--spacing-md);
}
.project-card h3 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-sm);
}
.project-card p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.5;
}
.project-status {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
margin-top: var(--spacing-sm);
}
.status-planned {
background: #fef3c7;
color: #92400e;
}
.status-in_progress {
background: #dbeafe;
color: #1e40af;
}
.status-completed {
background: #dcfce7;
color: #166534;
}
/* Stakeholders */
.stakeholders-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-2xl);
}
.stakeholder-card {
display: flex;
align-items: center;
gap: var(--spacing-md);
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.stakeholder-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-lg);
flex-shrink: 0;
}
.stakeholder-info h4 {
font-size: var(--font-size-base);
margin-bottom: 2px;
}
.stakeholder-info p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.stakeholder-category {
font-size: var(--font-size-xs);
padding: 2px 6px;
border-radius: var(--radius-sm);
background: #f3f4f6;
color: #374151;
}
.category-government { background: #fee2e2; color: #991b1b; }
.category-local_authority { background: #dbeafe; color: #1e40af; }
.category-business { background: #dcfce7; color: #166534; }
/* News */
.news-grid {
display: grid;
gap: var(--spacing-md);
margin-bottom: var(--spacing-2xl);
}
.news-card {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-md);
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
text-decoration: none;
color: inherit;
transition: var(--transition);
}
.news-card:hover {
box-shadow: var(--shadow-md);
}
.news-date {
text-align: center;
padding: var(--spacing-sm);
background: var(--background);
border-radius: var(--radius-sm);
min-width: 60px;
}
.news-date-day {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--primary);
}
.news-date-month {
font-size: var(--font-size-xs);
color: var(--text-secondary);
text-transform: uppercase;
}
.news-content h4 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
line-height: 1.4;
}
.news-content p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.5;
}
.news-source {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--spacing-xs);
}
/* Resources */
.resources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.resource-card {
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
text-decoration: none;
color: inherit;
transition: var(--transition);
text-align: center;
}
.resource-card:hover {
box-shadow: var(--shadow-md);
}
.resource-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--spacing-sm);
background: var(--background);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
}
.resource-card h4 {
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-xs);
}
.resource-type {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
@media (max-width: 768px) {
.zopk-hero {
padding: var(--spacing-xl) var(--spacing-md);
}
.zopk-hero h1 {
font-size: var(--font-size-2xl);
}
.projects-grid {
grid-template-columns: 1fr;
}
.news-card {
grid-template-columns: 1fr;
}
.news-date {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-start;
padding: var(--spacing-xs) var(--spacing-sm);
}
}
</style>
{% endblock %}
{% block content %}
<div class="zopk-hero">
<div class="container">
<h1>Zielony Okręg Przemysłowy Kaszubia</h1>
<p>Strategiczna inicjatywa rozwoju regionu Pomorza - energetyka odnawialna, przemysł obronny, technologie przyszłości. Baza wiedzy o projektach transformacji gospodarczej Kaszub.</p>
<div class="zopk-stats">
<div class="zopk-stat">
<div class="zopk-stat-value">{{ stats.total_projects }}</div>
<div class="zopk-stat-label">Projekty</div>
</div>
<div class="zopk-stat">
<div class="zopk-stat-value">{{ stats.total_stakeholders }}</div>
<div class="zopk-stat-label">Interesariusze</div>
</div>
<div class="zopk-stat">
<div class="zopk-stat-value">{{ stats.total_news }}</div>
<div class="zopk-stat-label">Aktualności</div>
</div>
<div class="zopk-stat">
<div class="zopk-stat-value">{{ stats.total_resources }}</div>
<div class="zopk-stat-label">Materiały</div>
</div>
</div>
</div>
</div>
<!-- Projects Section -->
<section>
<div class="section-header">
<h2>Projekty strategiczne</h2>
</div>
{% if projects %}
<div class="projects-grid">
{% for project in projects %}
<a href="{{ url_for('zopk_project_detail', slug=project.slug) }}" class="project-card" style="border-left-color: {{ project.color or '#059669' }}">
<div class="project-icon" style="background: {{ project.color or '#059669' }}20; color: {{ project.color or '#059669' }}">
{% if project.icon == 'wind' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2"/><path d="M9.6 4.6A2 2 0 1 1 11 8H2"/><path d="M12.6 19.4A2 2 0 1 0 14 16H2"/></svg>
{% elif project.icon == 'atom' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><path d="M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z"/><path d="M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z"/></svg>
{% elif project.icon == 'server' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></svg>
{% elif project.icon == 'flask' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 22h12l3-9H3l3 9Z"/><path d="M9 2v8l-3 5"/><path d="M15 2v8l3 5"/><line x1="9" x2="15" y1="2" y2="2"/></svg>
{% elif project.icon == 'shield' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
{% else %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
{% endif %}
</div>
<h3>{{ project.name }}</h3>
<p>{{ project.description[:150] }}{% if project.description|length > 150 %}...{% endif %}</p>
<span class="project-status status-{{ project.status }}">
{% if project.status == 'planned' %}Planowany{% elif project.status == 'in_progress' %}W realizacji{% else %}Zakończony{% endif %}
</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak projektów do wyświetlenia.</p>
</div>
{% endif %}
</section>
<!-- Stakeholders Section -->
<section>
<div class="section-header">
<h2>Kluczowi interesariusze</h2>
</div>
{% if stakeholders %}
<div class="stakeholders-list">
{% for stakeholder in stakeholders %}
<div class="stakeholder-card">
<div class="stakeholder-avatar">
{{ stakeholder.name[0].upper() }}
</div>
<div class="stakeholder-info">
<h4>{{ stakeholder.name }}</h4>
<p>{{ stakeholder.role or stakeholder.organization }}</p>
<span class="stakeholder-category category-{{ stakeholder.category }}">
{% if stakeholder.category == 'government' %}Rząd{% elif stakeholder.category == 'local_authority' %}Samorząd{% elif stakeholder.category == 'business' %}Biznes{% else %}{{ stakeholder.category }}{% endif %}
</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak interesariuszy do wyświetlenia.</p>
</div>
{% endif %}
</section>
<!-- News Section -->
<section>
<div class="section-header">
<h2>Aktualności</h2>
<a href="{{ url_for('zopk_news_list') }}" class="btn btn-secondary btn-sm">Zobacz wszystkie</a>
</div>
{% if news_items %}
<div class="news-grid">
{% for news in news_items %}
<a href="{{ news.url }}" target="_blank" rel="noopener" class="news-card">
<div class="news-date">
{% if news.published_at %}
<div class="news-date-day">{{ news.published_at.strftime('%d') }}</div>
<div class="news-date-month">{{ news.published_at.strftime('%b') }}</div>
{% else %}
<div class="news-date-day">--</div>
<div class="news-date-month">---</div>
{% endif %}
</div>
<div class="news-content">
<h4>{{ news.title }}</h4>
{% if news.description %}
<p>{{ news.description[:200] }}{% if news.description|length > 200 %}...{% endif %}</p>
{% endif %}
<div class="news-source">{{ news.source_name or news.source_domain }}</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak aktualności. Wkrótce pojawią się nowe informacje.</p>
</div>
{% endif %}
</section>
<!-- Resources Section -->
{% if resources %}
<section>
<div class="section-header">
<h2>Materiały i dokumenty</h2>
</div>
<div class="resources-grid">
{% for resource in resources %}
<a href="{{ resource.url or '#' }}" target="_blank" rel="noopener" class="resource-card">
<div class="resource-icon">
{% if resource.resource_type == 'document' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{% elif resource.resource_type == 'video' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect width="15" height="14" x="1" y="5" rx="2" ry="2"/></svg>
{% elif resource.resource_type == 'image' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
{% elif resource.resource_type == 'map' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" x2="8" y1="2" y2="18"/><line x1="16" x2="16" y1="6" y2="22"/></svg>
{% else %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
{% endif %}
</div>
<h4>{{ resource.title }}</h4>
<div class="resource-type">{{ resource.resource_type|capitalize }}</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,229 @@
{% extends "base.html" %}
{% block title %}Aktualności ZOPK - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.page-header {
margin-bottom: var(--spacing-xl);
}
.page-header h1 {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-sm);
}
.filters {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
}
.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);
font-size: var(--font-size-sm);
transition: var(--transition);
}
.filter-btn:hover {
background: var(--background);
}
.filter-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.news-grid {
display: grid;
gap: var(--spacing-lg);
}
.news-card {
display: grid;
grid-template-columns: 100px 1fr;
gap: var(--spacing-lg);
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-decoration: none;
color: inherit;
transition: var(--transition);
}
.news-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.news-image {
width: 100px;
height: 80px;
border-radius: var(--radius);
object-fit: cover;
background: var(--background);
}
.news-placeholder {
width: 100px;
height: 80px;
border-radius: var(--radius);
background: linear-gradient(135deg, #059669, #047857);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.news-content h3 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-sm);
line-height: 1.4;
}
.news-content p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: var(--spacing-sm);
}
.news-meta {
display: flex;
gap: var(--spacing-md);
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.pagination {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.pagination a,
.pagination span {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
}
.pagination a {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.pagination a:hover {
background: var(--background);
}
.pagination span.current {
background: var(--primary);
color: white;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
background: var(--surface);
border-radius: var(--radius-lg);
}
@media (max-width: 768px) {
.news-card {
grid-template-columns: 1fr;
}
.news-image,
.news-placeholder {
width: 100%;
height: 150px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<a href="{{ url_for('zopk_index') }}" class="text-muted" style="text-decoration: none; display: inline-flex; align-items: center; gap: var(--spacing-xs); margin-bottom: var(--spacing-sm);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Powrót do ZOPK
</a>
<h1>Aktualności</h1>
<p class="text-muted">{{ total }} artykułów o Zielonym Okręgu Przemysłowym Kaszubia</p>
</div>
{% if projects %}
<div class="filters">
<a href="{{ url_for('zopk_news_list') }}" class="filter-btn {% if not current_project %}active{% endif %}">Wszystkie</a>
{% for project in projects %}
<a href="{{ url_for('zopk_news_list', projekt=project.slug) }}" class="filter-btn {% if current_project == project.slug %}active{% endif %}">{{ project.name }}</a>
{% endfor %}
</div>
{% endif %}
{% if news_items %}
<div class="news-grid">
{% for news in news_items %}
<a href="{{ news.url }}" target="_blank" rel="noopener" class="news-card">
{% if news.image_url %}
<img src="{{ news.image_url }}" alt="" class="news-image">
{% else %}
<div class="news-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
</div>
{% endif %}
<div class="news-content">
<h3>{{ news.title }}</h3>
{% if news.description %}
<p>{{ news.description[:250] }}{% if news.description|length > 250 %}...{% endif %}</p>
{% endif %}
<div class="news-meta">
<span>{{ news.source_name or news.source_domain }}</span>
<span>{{ news.published_at.strftime('%d.%m.%Y') if news.published_at else '-' }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% if total_pages > 1 %}
<nav class="pagination">
{% if page > 1 %}
<a href="{{ url_for('zopk_news_list', page=page-1, projekt=current_project) }}">&laquo; Poprzednia</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="current">{{ p }}</span>
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
<a href="{{ url_for('zopk_news_list', page=p, projekt=current_project) }}">{{ p }}</a>
{% elif p == 4 or p == total_pages - 3 %}
<span>...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('zopk_news_list', page=page+1, projekt=current_project) }}">Następna &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak artykułów w wybranej kategorii.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,340 @@
{% extends "base.html" %}
{% block title %}{{ project.name }} - ZOPK{% endblock %}
{% block extra_css %}
<style>
.project-header {
background: linear-gradient(135deg, {{ project.color or '#059669' }}cc, {{ project.color or '#047857' }});
color: white;
padding: var(--spacing-2xl) 0;
margin-bottom: var(--spacing-xl);
border-radius: var(--radius-lg);
}
.project-header .container {
max-width: 800px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: rgba(255,255,255,0.8);
text-decoration: none;
margin-bottom: var(--spacing-md);
}
.back-link:hover {
color: white;
}
.project-status {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
background: rgba(255,255,255,0.2);
margin-bottom: var(--spacing-md);
}
.project-header h1 {
font-size: var(--font-size-3xl);
margin-bottom: var(--spacing-md);
}
.project-header p {
font-size: var(--font-size-lg);
opacity: 0.9;
line-height: 1.6;
}
.project-meta {
display: flex;
gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
flex-wrap: wrap;
}
.project-meta-item {
text-align: center;
}
.project-meta-value {
font-size: var(--font-size-xl);
font-weight: 700;
}
.project-meta-label {
font-size: var(--font-size-sm);
opacity: 0.8;
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-xl);
}
.section {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-lg);
}
.section h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.news-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.news-item {
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
text-decoration: none;
color: inherit;
transition: var(--transition);
}
.news-item:hover {
transform: translateX(4px);
}
.news-item h4 {
font-size: var(--font-size-base);
margin-bottom: var(--spacing-xs);
line-height: 1.4;
}
.news-item-meta {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.resources-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-md);
}
.resource-item {
text-align: center;
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
text-decoration: none;
color: inherit;
transition: var(--transition);
}
.resource-item:hover {
background: var(--border);
}
.resource-icon {
width: 40px;
height: 40px;
margin: 0 auto var(--spacing-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
}
.resource-item h5 {
font-size: var(--font-size-sm);
font-weight: 500;
}
.companies-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.company-link-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
text-decoration: none;
color: inherit;
}
.company-link-item:hover {
background: var(--border);
}
.company-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.company-info h5 {
font-size: var(--font-size-sm);
margin-bottom: 2px;
}
.company-info p {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: var(--spacing-lg);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
@media (max-width: 768px) {
.content-grid {
grid-template-columns: 1fr;
}
.project-header {
padding: var(--spacing-xl) var(--spacing-md);
}
.project-meta {
gap: var(--spacing-lg);
}
}
</style>
{% endblock %}
{% block content %}
<div class="project-header">
<div class="container">
<a href="{{ url_for('zopk_index') }}" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Powrót do ZOPK
</a>
<span class="project-status">
{% if project.status == 'planned' %}Planowany{% elif project.status == 'in_progress' %}W realizacji{% else %}{{ project.status }}{% endif %}
</span>
<h1>{{ project.name }}</h1>
{% if project.description %}
<p>{{ project.description }}</p>
{% endif %}
<div class="project-meta">
{% if project.region %}
<div class="project-meta-item">
<div class="project-meta-value">{{ project.region }}</div>
<div class="project-meta-label">Region</div>
</div>
{% endif %}
{% if project.estimated_investment %}
<div class="project-meta-item">
<div class="project-meta-value">{{ "{:,.0f}".format(project.estimated_investment|float).replace(",", " ") }} PLN</div>
<div class="project-meta-label">Szacowane inwestycje</div>
</div>
{% endif %}
{% if project.estimated_jobs %}
<div class="project-meta-item">
<div class="project-meta-value">{{ project.estimated_jobs }}</div>
<div class="project-meta-label">Szacowane miejsca pracy</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="content-grid">
<div class="main-content">
<!-- News -->
<div class="section">
<h2>Aktualności</h2>
{% if news_items %}
<div class="news-list">
{% for news in news_items %}
<a href="{{ news.url }}" target="_blank" rel="noopener" class="news-item">
<h4>{{ news.title }}</h4>
<div class="news-item-meta">
{{ news.source_name or news.source_domain }} &bull;
{{ news.published_at.strftime('%d.%m.%Y') if news.published_at else '-' }}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak aktualności o tym projekcie.</p>
</div>
{% endif %}
</div>
<!-- Resources -->
{% if resources %}
<div class="section">
<h2>Materiały</h2>
<div class="resources-list">
{% for resource in resources %}
<a href="{{ resource.url or '#' }}" target="_blank" rel="noopener" class="resource-item">
<div class="resource-icon">
{% if resource.resource_type == 'document' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{% elif resource.resource_type == 'video' %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect width="15" height="14" x="1" y="5" rx="2" ry="2"/></svg>
{% else %}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
{% endif %}
</div>
<h5>{{ resource.title }}</h5>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="sidebar">
<!-- Norda Companies -->
<div class="section">
<h2>Firmy Norda Biznes</h2>
{% if company_links %}
<div class="companies-list">
{% for link in company_links %}
<a href="{{ url_for('company_detail_by_slug', slug=link.company.slug) }}" class="company-link-item">
<div class="company-avatar">{{ link.company.name[0].upper() }}</div>
<div class="company-info">
<h5>{{ link.company.name }}</h5>
<p>{{ link.link_type|replace('_', ' ')|capitalize }}</p>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Brak powiązanych firm z Norda Biznes.</p>
<p style="margin-top: var(--spacing-sm);">Firmy mogą zgłosić swoje zainteresowanie projektem.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}