first push message

This commit is contained in:
2026-07-01 14:41:49 +07:00
parent 6667dec2bf
commit 58b5f46cc4
2951 changed files with 316619 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
from . import controllers
from . import models
+46
View File
@@ -0,0 +1,46 @@
{
'name': "cpp_entry",
'summary': "Short (1 phrase/line) summary of the module's purpose",
'description': """
Long description of module's purpose
""",
'author': "My Company",
'website': "https://www.yourcompany.com",
# Categories can be used to filter modules in modules listing
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
# for the full list
'category': 'Uncategorized',
'version': '0.1',
# any module necessary for this one to work correctly
'depends': ['base','web','address_kh','mail','portal','website','youth_and_scholarship'],
# always loaded
'data': [
'security/ir.model.access.csv',
'security/portal_rules.xml',
'views/portal_templates.xml',
'views/dashboard_voter.xml',
'views/portal_menu.xml',
'data/gender_data.xml',
'views/name_party.xml',
'views/party_list.xml',
'views/party_voters_detail.xml',
],
# only loaded in demonstration mode
'assets': {
'web.assets_frontend': [
'cpp_entry/static/src/scss/cpp_portal.scss',
'cpp_entry/static/src/js/cpp_portal.js',
'cpp_entry/static/src/js/location_cascade.js',
],
},
'installable': True,
'application': False,
'auto_install': False,
}
+1
View File
@@ -0,0 +1 @@
from . import portal
+660
View File
@@ -0,0 +1,660 @@
import base64
import datetime
import pytz
from datetime import datetime
from odoo import http, _,fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
import logging
_logger = logging.getLogger(__name__)
class CppPortal(CustomerPortal):
@http.route(['/my/cpp_entries'], type='http', auth="user", website=True)
def portal_cpp_entries(self, **kw):
"""List all CPP entries for current portal user"""
domain = [('create_uid', '=', request.env.user.id)]
entries = request.env['cpp.entry'].search(domain, order='create_date desc')
return request.render('cpp_entry.portal_my_cpp_entries', {
'entries': entries,
'page_name': 'cpp_entries',
'error': kw.get('error'),
})
@http.route(['/my/cpp_entries/new', '/my/cpp_entries/<int:entry_id>'],
type='http', auth="user", website=True)
def portal_cpp_form(self, entry_id=None, **kw):
if entry_id:
entry = request.env['cpp.entry'].browse(int(entry_id))
if not entry.exists():
return request.redirect('/my/cpp_entries?error=Entry+not+found')
if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'):
raise AccessError(_("Access denied"))
else:
entry = request.env['cpp.entry']
# Load provinces
provinces = request.env['address.address'].sudo().search([
('parent_location', '=', False)
], order='location_name asc')
# Load districts for selected province
districts = request.env['address.address']
if entry.province_id:
districts = request.env['address.address'].sudo().search([
('parent_location', '=', entry.province_id.id)
], order='location_name asc')
# Load communes for selected district
communes = request.env['address.address']
if entry.district_id:
communes = request.env['address.address'].sudo().search([
('parent_location', '=', entry.district_id.id)
], order='location_name asc')
# SEPARATE VOTERS BY STATUS
voters_not_voted = request.env['info.voter']
voters_voted = request.env['info.voter']
if entry and entry.exists():
# Get all voters for this entry
all_voters = request.env['info.voter'].search([
('cpp_entry', '=', entry.id)
], order='name asc')
# Separate into two lists
voters_not_voted = all_voters.filtered(lambda v: not v.status_vote)
voters_voted = all_voters.filtered(lambda v: v.status_vote)
return request.render('cpp_entry.portal_cpp_form', {
'entry': entry,
'provinces': provinces,
'districts': districts,
'communes': communes,
'voters_not_voted': voters_not_voted, # Pass to template
'voters_voted': voters_voted, # Pass to template
'page_name': 'cpp_entries',
})
@http.route(['/my/cpp_entries/submit'], type='http', auth="user", website=True, csrf=True, methods=['POST'])
def submit_cpp_entry(self, **post):
"""Handle main entry form submission"""
entry_id = post.get('entry_id')
try:
# ✅ Validation: Ensure Province is selected
if not post.get('province_id') or not str(post['province_id']).isdigit():
return request.redirect('/my/cpp_entries/new?error=Please+select+a+Province+(ខេត្ដ/ក្រុង)')
vals = {}
if post.get('province_id') and str(post['province_id']).isdigit():
vals['province_id'] = int(post['province_id'])
if post.get('district_id') and str(post['district_id']).isdigit():
vals['district_id'] = int(post['district_id'])
if post.get('commune_id') and str(post['commune_id']).isdigit():
vals['commune_id'] = int(post['commune_id'])
# ✅ Handle al_office field from model
if post.get('al_office'):
vals['al_office'] = post.get('al_office').strip()
if entry_id and str(entry_id) != '0' and str(entry_id).isdigit():
entry = request.env['cpp.entry'].browse(int(entry_id))
if not entry.exists():
return request.redirect('/my/cpp_entries?error=Entry+not+found')
entry.write(vals)
else:
entry = request.env['cpp.entry'].create(vals)
return request.redirect(f'/my/cpp_entries/{entry.id}')
except Exception as e:
error_msg = str(e).replace(' ', '+')
return request.redirect(f'/my/cpp_entries?error={error_msg}')
@http.route(['/my/cpp_entries/voter/new'], type='http', auth="user", website=True)
def portal_voter_new(self, entry_id=0, status_vote=0, **kw):
"""Show form to add new voter"""
try:
entry_id = int(entry_id) if entry_id else 0
except (ValueError, TypeError):
entry_id = 0
entry = request.env['cpp.entry'].browse(entry_id) if entry_id else None
if not entry or not entry.exists():
return request.redirect('/my/cpp_entries?error=Please+create+CPP+entry+first')
if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'):
raise AccessError(_("Access denied"))
genders = []
try:
if 'gender.gender' in request.env:
genders = request.env['gender.gender'].search([], order='name')
except Exception:
pass
return request.render('cpp_entry.portal_voter_form', {
'entry': entry,
'voter': request.env['info.voter'],
'genders': genders,
'status_vote': int(status_vote),
'page_name': 'cpp_entries',
})
@http.route(['/my/cpp_entries/voter/<int:voter_id>/edit'], type='http', auth="user", website=True)
def portal_voter_edit(self, voter_id, **kw):
"""Show form to edit existing voter"""
voter = request.env['info.voter'].browse(voter_id)
if not voter.exists():
return request.redirect('/my/cpp_entries?error=Voter+not+found')
if voter.cpp_entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'):
raise AccessError(_("Access denied"))
genders = []
try:
if 'gender.gender' in request.env:
genders = request.env['gender.gender'].search([], order='name')
except Exception:
pass
return request.render('cpp_entry.portal_voter_form', {
'entry': voter.cpp_entry,
'voter': voter,
'genders': genders,
'status_vote': 1 if voter.status_vote else 0,
'page_name': 'cpp_entries',
})
@http.route(['/my/cpp_entries/voter/submit'],
type='http', auth="user", website=True, csrf=True, methods=['POST'])
def submit_voter(self, **post):
"""Handle voter form submission with strict validation."""
entry_id = post.get('entry_id', '')
voter_id = post.get('voter_id', '0')
try:
raw_status = post.get('status_vote')
if raw_status:
# Convert to string, make lowercase, and check if it matches "checked" values
is_voted = str(raw_status).lower() in ('on', '1', 'true', 'yes')
else:
# If unchecked, the form sends nothing (None or empty string)
is_voted = False
# 1. VALIDATE ENTRY ID
if not entry_id or not str(entry_id).isdigit():
raise ValueError("Invalid or missing Entry ID. Cannot save voter.")
entry = request.env['cpp.entry'].browse(int(entry_id))
if not entry.exists():
raise ValueError("CPP Entry not found.")
if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'):
raise AccessError(_("Permission denied"))
# 2. VALIDATE NAME (Matches @api.constrains in model)
name = post.get('name', '').strip()
if len(name) < 2:
raise ValidationError(_("ឈ្មោះត្រូវមានយ៉ាងតិច ២ តួអក្សរ (Name must be at least 2 characters)"))
vals = {
'cpp_entry': entry.id,
'name': name,
'address': post.get('address', '').strip(),
'phone': post.get('phone', '').strip(),
'status_vote': is_voted,
}
# 3. SAFE GENDER CONVERSION
gender_id = post.get('gender', '')
if gender_id and str(gender_id).isdigit():
vals['gender'] = int(gender_id)
# 4. DATE OF BIRTH (Optional)
dob = post.get('dob')
if dob:
vals['dob'] = dob
# 5. PHOTO UPLOAD / DELETION
photo = request.httprequest.files.get('photo')
if photo and photo.filename:
# Check file extension
ext = photo.filename.rsplit('.', 1)[-1].lower()
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'bmp'):
raise ValidationError(_("Invalid file type. Only images allowed."))
# Check file size (2MB max)
photo.seek(0, 2) # Seek to end
size = photo.tell() # Get size
photo.seek(0) # Reset to beginning
if size > 2 * 1024 * 1024: # 2MB
raise ValidationError(_("File size exceeds 2MB limit."))
# Read and encode
photo_data = photo.read()
vals['photo'] = base64.b64encode(photo_data)
# Debug: Log if photo is being saved
_logger.info(f"Photo uploaded: {photo.filename}, Size: {size} bytes")
# 6. CREATE OR UPDATE
if voter_id and str(voter_id) != '0' and str(voter_id).isdigit():
voter = request.env['info.voter'].browse(int(voter_id))
if not voter.exists():
raise ValueError("Voter not found.")
voter.write(vals)
else:
request.env['info.voter'].create(vals)
return request.redirect(f'/my/cpp_entries/{entry.id}')
except Exception as e:
error_msg = str(e).replace(' ', '+')
# Redirect back to the entry page with the error message
if entry_id and str(entry_id).isdigit():
return request.redirect(f'/my/cpp_entries/{entry_id}?error={error_msg}')
return request.redirect(f'/my/cpp_entries?error={error_msg}')
@http.route(['/my/cpp_entries/voter/<int:voter_id>/delete'],
type='http', auth="user", website=True, csrf=True)
def delete_voter(self, voter_id, **kw):
"""Delete a voter"""
voter = request.env['info.voter'].browse(voter_id)
if not voter.exists():
return request.redirect('/my/cpp_entries?error=Voter+not+found')
entry = voter.cpp_entry
if entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'):
raise AccessError(_("Permission denied"))
voter.unlink()
return request.redirect(f'/my/cpp_entries/{entry.id}')
@http.route(['/cpp_entry/get_districts'], type='http', auth='user', website=True)
def get_districts(self, province_id, **kw):
if not province_id or not str(province_id).isdigit():
return request.make_json_response([])
districts = request.env['address.address'].sudo().search([
('parent_location', '=', int(province_id))
], order='location_name asc')
result = [{'id': d.id, 'name': d.location_name or d.name} for d in districts]
return request.make_json_response(result)
@http.route(['/cpp_entry/get_communes'], type='http', auth='user', website=True)
def get_communes(self, district_id, **kw):
if not district_id or not str(district_id).isdigit():
return request.make_json_response([])
communes = request.env['address.address'].sudo().search([
('parent_location', '=', int(district_id))
], order='location_name asc')
result = [{'id': c.id, 'name': c.location_name or c.name} for c in communes]
return request.make_json_response(result)
@http.route(['/my/cpp_entries/voter/<int:voter_id>/toggle_vote'],
type='http', auth="user", website=True, csrf=True)
def toggle_voter_status(self, voter_id, **kw):
"""Quick toggle for status_vote"""
voter = request.env['info.voter'].browse(voter_id)
if not voter.exists():
return request.redirect('/my/cpp_entries?error=Voter+not+found')
# Security check
if voter.cpp_entry.create_uid != request.env.user and not request.env.user.has_group('base.group_user'):
raise AccessError(_("Permission denied"))
# Toggle the boolean value
voter.write({'status_vote': not voter.status_vote})
# Redirect back to the entry page
return request.redirect(f'/my/cpp_entries/{voter.cpp_entry.id}')
@http.route(['/my/cpp_entries/dashboard'], type='http', auth="user", website=True)
def cpp_dashboard(self, **kw):
user = request.env.user
# ✅ FIX: Get correct local datetime
utc_now = datetime.utcnow()
user_tz = request.env.user.tz or 'UTC'
tz = pytz.timezone(user_tz)
current_dt = pytz.utc.localize(utc_now).astimezone(tz)
# 1. BASE DOMAIN
domain = []
if not user.has_group('base.group_system'):
domain.append(('create_uid', '=', user.id))
# 2. GET FILTER VALUES
province_id = kw.get('province_id')
district_id = kw.get('district_id')
commune_id = kw.get('commune_id')
al_office = kw.get('al_office', '').strip()
company_id = kw.get('company_id')
# 3. APPLY FILTERS TO DOMAIN
if province_id and province_id.isdigit():
domain.append(('province_id', '=', int(province_id)))
if district_id and district_id.isdigit():
domain.append(('district_id', '=', int(district_id)))
if commune_id and commune_id.isdigit():
domain.append(('commune_id', '=', int(commune_id)))
if al_office:
domain.append(('al_office', 'ilike', al_office))
if company_id and company_id.isdigit():
domain.append(('company_id', '=', int(company_id)))
all_entries = request.env['cpp.entry'].sudo().search(domain)
# 4. DATA AGGREGATION (Same logic as before)
hours = list(range(8, 16))
commune_data = {}
totals = {
'voters': 0, 'voted': 0,
'members': 0, 'members_voted': 0,
'non_members': 0, 'non_members_voted': 0
}
def safe_pct(num, den):
return (num / den * 100) if den > 0 else 0.0
for entry in all_entries:
voters = entry.info_ids
commune_name = entry.commune_id.location_name if entry.commune_id else "មិនកំណត់"
if commune_name not in commune_data:
commune_data[commune_name] = {
'total': 0, 'voted': 0,
'members': 0, 'members_voted': 0,
'non_members': 0, 'non_members_voted': 0,
'hourly': {h: 0 for h in hours}
}
voted_voters = voters.filtered(lambda v: v.status_vote)
members = voters.filtered(lambda v: v.status == '1')
non_members = voters.filtered(lambda v: v.status == '2')
members_voted = members.filtered(lambda v: v.status_vote)
non_members_voted = non_members.filtered(lambda v: v.status_vote)
c = commune_data[commune_name]
c['total'] += len(voters)
c['voted'] += len(voted_voters)
c['members'] += len(members)
c['members_voted'] += len(members_voted)
c['non_members'] += len(non_members)
c['non_members_voted'] += len(non_members_voted)
totals['voters'] += len(voters)
totals['voted'] += len(voted_voters)
totals['members'] += len(members)
totals['members_voted'] += len(members_voted)
totals['non_members'] += len(non_members)
totals['non_members_voted'] += len(non_members_voted)
for v in voted_voters:
if v.create_date:
h = v.create_date.hour
if h in hours:
c['hourly'][h] += 1
# Calculate percentages
totals['members_pct'] = safe_pct(totals['members_voted'], totals['members'])
totals['non_members_pct'] = safe_pct(totals['non_members_voted'], totals['non_members'])
for data in commune_data.values():
data['pct'] = safe_pct(data['voted'], data['total'])
data['members_pct'] = safe_pct(data['members_voted'], data['members'])
data['non_members_pct'] = safe_pct(data['non_members_voted'], data['non_members'])
cum = 0
for h in hours:
cum += data['hourly'][h]
data['hourly'][h] = {
'count': data['hourly'][h],
'cum': cum,
'pct': safe_pct(cum, data['total'])
}
overall_pct = safe_pct(totals['voted'], totals['voters'])
# 5. PREPARE DROPDOWN DATA
# Provinces (Top level addresses)
provinces = request.env['address.address'].sudo().search(
[('parent_location', '=', False)], order='location_name asc'
)
# Districts (Children of selected province)
districts = request.env['address.address']
if province_id and province_id.isdigit():
districts = request.env['address.address'].sudo().search(
[('parent_location', '=', int(province_id))], order='location_name asc'
)
else:
# If no province selected, show all districts (optional, or keep empty)
# districts = request.env['address.address'].sudo().search([('parent_location', '!=', False)])
pass
# Communes (Children of selected district)
communes = request.env['address.address']
if district_id and district_id.isdigit():
communes = request.env['address.address'].sudo().search(
[('parent_location', '=', int(district_id))], order='location_name asc'
)
# Companies
companies = request.env['res.company'].sudo().search([], order='name asc')
return request.render('cpp_entry.cpp_dashboard', {
'current_dt': current_dt,
'hours': hours,
'commune_data': commune_data,
'totals': totals,
'overall_pct': overall_pct,
'provinces': provinces,
'districts': districts,
'communes': communes,
'companies': companies,
'sel_province': province_id,
'sel_district': district_id,
'sel_commune': commune_id,
'sel_al_office': al_office,
'sel_company': company_id,
'page_name': 'cpp_dashboard',
})
@http.route(['/my/cpp_entries/party_list'], type='http', auth="user", website=True)
def party_list(self, **kw):
"""List all parties with voter counts"""
user = request.env.user
current_dt = datetime.now()
# Get user's timezone
user_tz = request.env.user.tz or 'UTC'
tz = pytz.timezone(user_tz)
current_dt = pytz.utc.localize(current_dt).astimezone(tz)
# Base domain
domain = []
if not user.has_group('base.group_system'):
domain.append(('create_uid', '=', user.id))
# Apply location filters
district_id = kw.get('district_id')
commune_id = kw.get('commune_id')
if district_id and district_id.isdigit():
domain.append(('district_id', '=', int(district_id)))
if commune_id and commune_id.isdigit():
domain.append(('commune_id', '=', int(commune_id)))
all_entries = request.env['cpp.entry'].sudo().search(domain)
# Aggregate voters by party/status
party_stats = {}
total_voters = 0
for entry in all_entries:
voters = entry.info_ids
for voter in voters:
# Determine party
if voter.status == '1':
party_name = "គណបក្សប្រជាជនកម្ពុជា (CPP)"
party_key = 'cpp'
elif voter.status == '2':
party_name = "មិនមែនសមាជិកគណបក្ស"
party_key = 'non_member'
else:
party_name = "មិនកំណត់"
party_key = 'unknown'
if party_key not in party_stats:
party_stats[party_key] = {
'name': party_name,
'total': 0,
'voted': 0,
'not_voted': 0,
'members': 0,
'male': 0,
'female': 0
}
stats = party_stats[party_key]
stats['total'] += 1
if voter.status_vote:
stats['voted'] += 1
else:
stats['not_voted'] += 1
if voter.gender:
if voter.gender.name == 'ប្រុស':
stats['male'] += 1
elif voter.gender.name == 'ស្រី':
stats['female'] += 1
total_voters += 1
# Get location filters for template
districts = request.env['address.address'].sudo().search(
[('parent_location', '=', False)], order='location_name asc'
)
communes = request.env['address.address']
if district_id and district_id.isdigit():
communes = request.env['address.address'].sudo().search(
[('parent_location', '=', int(district_id))], order='location_name asc'
)
return request.render('cpp_entry.party_list', {
'current_dt': current_dt,
'party_stats': party_stats,
'total_voters': total_voters,
'districts': districts,
'communes': communes,
'sel_district': district_id,
'sel_commune': commune_id,
'page_name': 'party_list',
})
@http.route(['/my/cpp_entries/party_voters/<string:party_key>'], type='http', auth="user", website=True)
def party_voters_detail(self, party_key, **kw):
"""Show detailed list of voters for a specific party"""
user = request.env.user
current_dt = datetime.now()
# Get user's timezone
user_tz = request.env.user.tz or 'UTC'
tz = pytz.timezone(user_tz)
current_dt = pytz.utc.localize(current_dt).astimezone(tz)
# Base domain
domain = []
if not user.has_group('base.group_system'):
domain.append(('create_uid', '=', user.id))
# Apply location filters
district_id = kw.get('district_id')
commune_id = kw.get('commune_id')
if district_id and district_id.isdigit():
domain.append(('district_id', '=', int(district_id)))
if commune_id and commune_id.isdigit():
domain.append(('commune_id', '=', int(commune_id)))
all_entries = request.env['cpp.entry'].sudo().search(domain)
# Collect voters by party
voters_list = []
party_name = ""
for entry in all_entries:
for voter in entry.info_ids:
# Determine party
if voter.status == '1':
voter_party = 'cpp'
voter_party_name = "គណបក្សប្រជាជនកម្ពុជា (CPP)"
elif voter.status == '2':
voter_party = 'non_member'
voter_party_name = "មិនមែនសមាជិកគណបក្ស"
else:
voter_party = 'unknown'
voter_party_name = "មិនកំណត់"
# Filter by requested party
if voter_party == party_key:
party_name = voter_party_name
voters_list.append({
'id': voter.id,
'name': voter.name,
'gender': voter.gender.name if voter.gender else '-',
'dob': voter.dob,
'phone': voter.phone,
'address': voter.address,
'status_vote': voter.status_vote,
'commune': entry.commune_id.location_name if entry.commune_id else '-',
'district': entry.district_id.location_name if entry.district_id else '-',
})
# Sort by name
voters_list.sort(key=lambda x: x['name'])
# Statistics
total = len(voters_list)
voted = len([v for v in voters_list if v['status_vote']])
not_voted = total - voted
male = len([v for v in voters_list if v['gender'] == 'ប្រុស'])
female = len([v for v in voters_list if v['gender'] == 'ស្រី'])
# Get location filters
districts = request.env['address.address'].sudo().search(
[('parent_location', '=', False)], order='location_name asc'
)
communes = request.env['address.address']
if district_id and district_id.isdigit():
communes = request.env['address.address'].sudo().search(
[('parent_location', '=', int(district_id))], order='location_name asc'
)
return request.render('cpp_entry.party_voters_detail', {
'current_dt': current_dt,
'party_key': party_key,
'party_name': party_name,
'voters_list': voters_list,
'total': total,
'voted': voted,
'not_voted': not_voted,
'male': male,
'female': female,
'districts': districts,
'communes': communes,
'sel_district': district_id,
'sel_commune': commune_id,
'page_name': 'party_voters',
})
+22
View File
@@ -0,0 +1,22 @@
<odoo>
<data>
<record id="gender_1" model="gender.gender">
<field name="name">ប្រុស</field>
</record>
<record id="gender_2" model="gender.gender">
<field name="name">ស្រី</field>
</record>
<record id="gender_3" model="gender.gender">
<field name="name">ព្រះសង្ឃ</field>
</record>
<record id="p_1" model="person.member">
<field name="name">គណបក្សប្រជាជន</field>
</record>
<record id="p_2" model="person.member">
<field name="name">គណបក្សខ្មែរជំនាន់ថ្មី</field>
</record>
<record id="p_3" model="person.member">
<field name="name">គណបក្សខ្មែរក្រោក</field>
</record>
</data>
</odoo>
+10
View File
@@ -0,0 +1,10 @@
<odoo>
<data>
<record id="gender_1" model="gender.gender">
<field name="name">ប្រុស</field>
</record>
<record id="gender_2" model="gender.gender">
<field name="name">ស្រី</field>
</record>
</data>
</odoo>
+1
View File
@@ -0,0 +1 @@
from . import cpp_entry
+88
View File
@@ -0,0 +1,88 @@
# cpp_entry/models/cpp_entry.py
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class CppEntry(models.Model):
_name = 'cpp.entry'
_description = 'CPP Patient Treatment Entry'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
active = fields.Boolean(default=True, string="សកម្ម")
# Location fields (adjust model names to match your address module)
province_id = fields.Many2one('address.address', string="ខេត្ត/ក្រុង (Province)")
district_id = fields.Many2one('address.address', string="ស្រុក/ខណ្ឌ (District)")
commune_id = fields.Many2one('address.address', string="ឃុំ/សង្កាត់ (Commune)")
al_office = fields.Char(string="ការិយាល័យបោះឆ្នោត")
company_id = fields.Many2one('res.company', string="Company")
# One2many to voters - note: field name ends with _ids per Odoo convention
info_ids = fields.One2many(
'info.voter',
'cpp_entry',
string="បញ្ជីឈ្មោះអ្នកមិនទាន់បោះឆ្នោត"
)
# Computed fields for display
voter_count = fields.Integer(
string="ចំនួនអ្នកបោះឆ្នោត",
compute='_compute_voter_counts',
store=False
)
non_voter_count = fields.Integer(
string="ចំនួនអ្នកមិនទាន់បោះឆ្នោត",
compute='_compute_voter_counts',
store=False
)
@api.depends('info_ids', 'info_ids.status_vote')
def _compute_voter_counts(self):
for record in self:
record.voter_count = len(record.info_ids.filtered(lambda r: r.status_vote))
record.non_voter_count = len(record.info_ids.filtered(lambda r: not r.status_vote))
class MemberCpp(models.Model):
_name = 'person.member'
_inherit = ['mail.thread', 'mail.activity.mixin']
_description = 'CPP Patient Treatment Entry'
name = fields.Char(string="ឈ្មោះគណបក្ស")
short_name = fields.Char(string="ឈ្មោះខ្លី")
class InfoVoter(models.Model):
_name = 'info.voter'
_description = 'Voter Information'
_order = 'name'
cpp_entry = fields.Many2one(
'cpp.entry',
string="CPP Entry",
)
# Personal Info
photo = fields.Binary(string="រូបថត", attachment=True)
name = fields.Char(string="ឈ្មោះ", required=True, index=True)
gender = fields.Many2one('gender.gender', string="ភេទ")
dob = fields.Date(string="ថ្ងៃខែឆ្នាំកំណើត")
phone = fields.Char(string="លេខទូរស័ព្ទ")
address = fields.Char(string="អាសយដ្ឋាន")
status = fields.Selection([('1','សមាជិក'),('2','មិនមែនសមាជិក')],string="ស្ថានភាពសមាជិក")
cpp_member=fields.Many2one('person.member',string="សមាជិកគណបក្ស")
current_time = fields.Datetime(string="Date Checked")
# Voting Status
status_vote = fields.Boolean(
string="បានបោះឆ្នោត",
default=False,
help="Check if this person has already voted"
)
# Metadata
create_date = fields.Datetime(string="ថ្ងៃបង្កើត", readonly=True)
@api.constrains('name')
def _check_name(self):
for record in self:
if record.name and len(record.name.strip()) < 2:
raise ValidationError(_('ឈ្មោះត្រូវមានយ៉ាងតិច ២ តួអក្សរ'))
+6
View File
@@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_cpp_entry_user,cpp.entry.user,model_cpp_entry,base.group_user,1,1,1,1
access_cpp_entry_portal,cpp.entry.portal,model_cpp_entry,base.group_portal,1,1,1,0
access_info_voter_user,info.voter.user,model_info_voter,base.group_user,1,1,1,1
access_person_member,info_person_member,model_person_member,base.group_user,1,1,1,1
access_info_voter_portal,info.voter.portal,model_info_voter,base.group_portal,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cpp_entry_user cpp.entry.user model_cpp_entry base.group_user 1 1 1 1
3 access_cpp_entry_portal cpp.entry.portal model_cpp_entry base.group_portal 1 1 1 0
4 access_info_voter_user info.voter.user model_info_voter base.group_user 1 1 1 1
5 access_person_member info_person_member model_person_member base.group_user 1 1 1 1
6 access_info_voter_portal info.voter.portal model_info_voter base.group_portal 1 1 1 0
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="cpp_entry_portal_rule" model="ir.rule">
<field name="name">CPP Entry: Portal users see only own records</field>
<field name="model_id" ref="model_cpp_entry"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="info_voter_portal_rule" model="ir.rule">
<field name="name">Voter Info: Portal users see only own entries' voters</field>
<field name="model_id" ref="model_info_voter"/>
<field name="domain_force">[('cpp_entry.create_uid', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
</odoo>
+167
View File
@@ -0,0 +1,167 @@
/** @odoo-module **/
import { jsonrpc } from "@web/core/network/rpc_service";
document.addEventListener('DOMContentLoaded', function() {
console.log('=== CPP Portal JS Loaded ===');
setupCascadingDropdowns();
setupPhotoPreview();
});
function setupCascadingDropdowns() {
const provinceSelect = document.getElementById('province_select');
const districtSelect = document.getElementById('district_select');
const communeSelect = document.getElementById('commune_select');
if (!provinceSelect || !districtSelect || !communeSelect) return;
// Province change handler
provinceSelect.addEventListener('change', async function() {
const provinceId = this.value;
districtSelect.innerHTML = '<option value="">-- ជ្រើសរើសស្រុក/ខណ្ឌ --</option>';
communeSelect.innerHTML = '<option value="">-- ជ្រើសរើសឃុំ/សង្កាត់ --</option>';
if (!provinceId) return;
try {
const districts = await jsonrpc('/cpp_entry/get_districts', {
province_id: provinceId
});
districts.forEach(district => {
const option = document.createElement('option');
option.value = district.id;
option.textContent = district.name;
districtSelect.appendChild(option);
});
} catch (error) {
console.error('Error loading districts:', error);
}
});
// District change handler
districtSelect.addEventListener('change', async function() {
const districtId = this.value;
communeSelect.innerHTML = '<option value="">-- ជ្រើសរើសឃុំ/សង្កាត់ --</option>';
if (!districtId) return;
try {
const communes = await jsonrpc('/cpp_entry/get_communes', {
district_id: districtId
});
communes.forEach(commune => {
const option = document.createElement('option');
option.value = commune.id;
option.textContent = commune.name;
communeSelect.appendChild(option);
});
} catch (error) {
console.error('Error loading communes:', error);
}
});
}
// ✅ PHOTO PREVIEW FUNCTION
function setupPhotoPreview() {
const photoInput = document.getElementById('photo_upload');
const photoPreview = document.getElementById('photo_preview');
const photoPlaceholder = document.querySelector('.photo-placeholder');
console.log('Photo Input:', photoInput);
console.log('Photo Preview:', photoPreview);
if (!photoInput) {
console.log('No photo upload input found');
return;
}
photoInput.addEventListener('change', function(e) {
console.log('Photo file selected');
const file = e.target.files[0];
if (!file) {
console.log('No file selected');
return;
}
console.log('File:', file.name, 'Size:', file.size, 'Type:', file.type);
// Validate file type
if (!file.type.match('image.*')) {
alert('⚠️ សូមជ្រើសរើសផ្ទាំងរូបភាព (JPEG, PNG, GIF)');
this.value = '';
return;
}
// Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) {
alert('⚠️ រូបភាពត្រូវតែតូចជាង 2MB');
this.value = '';
return;
}
// Read and preview the file
const reader = new FileReader();
reader.onload = function(event) {
console.log('File loaded, previewing...');
if (photoPreview) {
// Update existing preview image
photoPreview.src = event.target.result;
photoPreview.style.display = 'block';
console.log('Updated existing photo preview');
} else {
// Create new preview image if it doesn't exist
console.log('Creating new photo preview element');
const container = photoInput.closest('.photo-upload-container');
if (!container) {
console.error('Photo upload container not found');
return;
}
// Remove placeholder if exists
if (photoPlaceholder) {
photoPlaceholder.remove();
}
// Create new img element
const newPreview = document.createElement('img');
newPreview.id = 'photo_preview';
newPreview.className = 'rounded-circle border border-3 border-light shadow';
newPreview.style.cssText = 'width:120px;height:120px;object-fit:cover;display:block;';
newPreview.src = event.target.result;
newPreview.alt = 'Profile Photo';
// Insert before the camera button
const cameraLabel = container.querySelector('label[for="photo_upload"]');
if (cameraLabel) {
container.insertBefore(newPreview, cameraLabel);
} else {
container.appendChild(newPreview);
}
console.log('New photo preview created and inserted');
}
};
reader.onerror = function(error) {
console.error('Error reading file:', error);
alert('⚠️ មានបញ្ហាក្នុងការអានឯកសារ');
};
console.log('Reading file as Data URL...');
reader.readAsDataURL(file);
});
}
// Export for potential use
export const CppPortalUtils = {
setupPhotoPreview,
setupCascadingDropdowns,
};
@@ -0,0 +1,93 @@
/**
* CPP Location Cascade - Plain Vanilla JS
* NO @odoo-module tag, NO imports.
* This script works natively in the browser.
*/
(function() {
'use strict';
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
const provinceSelect = document.getElementById('province_select');
const districtSelect = document.getElementById('district_select');
const communeSelect = document.getElementById('commune_select');
if (!provinceSelect) return; // Stop if not on the right page
// 1. Province Change Event
provinceSelect.addEventListener('change', function() {
const provinceId = this.value;
// Clear District and Commune
districtSelect.innerHTML = '<option value="">-- កំពុងផ្ទុក... --</option>';
districtSelect.disabled = true;
communeSelect.innerHTML = '<option value="">-- ជ្រើសរើសស្រុក/ខណ្ឌជាមុន --</option>';
communeSelect.disabled = true;
if (!provinceId) {
districtSelect.innerHTML = '<option value="">-- ជ្រើសរើសស្ុក/ខណ្ឌ --</option>';
districtSelect.disabled = false;
return;
}
// Fetch Districts using standard browser fetch
fetch(`/cpp_entry/get_districts?province_id=${provinceId}`)
.then(response => response.json())
.then(data => {
districtSelect.innerHTML = '<option value="">-- ជ្រើសរើសស្រុក/ខណ្ --</option>';
data.forEach(item => {
let option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
districtSelect.appendChild(option);
});
districtSelect.disabled = false;
})
.catch(error => {
console.error('Error loading districts:', error);
districtSelect.innerHTML = '<option value="">-- បរាជ័យ --</option>';
});
});
// 2. District Change Event
districtSelect.addEventListener('change', function() {
const districtId = this.value;
// Clear Commune
communeSelect.innerHTML = '<option value="">-- កំពុងផ្ទុក... --</option>';
communeSelect.disabled = true;
if (!districtId) {
communeSelect.innerHTML = '<option value="">-- ជ្រើសរើសឃុំ/សង្កាត់ --</option>';
communeSelect.disabled = false;
return;
}
// Fetch Communes
fetch(`/cpp_entry/get_communes?district_id=${districtId}`)
.then(response => response.json())
.then(data => {
communeSelect.innerHTML = '<option value="">-- ជ្រើសរើសឃុំ/សង្កាត់ --</option>';
data.forEach(item => {
let option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
communeSelect.appendChild(option);
});
communeSelect.disabled = false;
})
.catch(error => {
console.error('Error loading communes:', error);
communeSelect.innerHTML = '<option value="">-- បរាជ័យ --</option>';
});
});
}
})();
+87
View File
@@ -0,0 +1,87 @@
// cpp_entry/static/src/scss/cpp_portal.scss
.o_portal_cpp_form {
// Photo upload enhancements
.photo-upload-container {
.photo-preview,
.photo-placeholder {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.photo-preview:hover {
transform: scale(1.03);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
label[for="photo_upload"] {
transition: all 0.2s ease;
&:hover {
background-color: var(--bs-primary) !important;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
// Table improvements
.table-responsive {
@media (max-width: 767px) {
font-size: 0.85rem;
th, td {
padding: 0.4rem !important;
white-space: nowrap;
}
.btn-sm {
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
}
}
}
// Form enhancements
.form-control-lg,
.form-select-lg {
@media (max-width: 576px) {
font-size: 1rem;
padding: 0.5rem 0.75rem;
}
}
// Badge colors for gender
.badge {
&.bg-info {
background-color: #0dcaf0 !important;
}
&.bg-danger {
background-color: #dc3545 !important;
}
}
// Card header icons
.card-header {
i {
margin-right: 0.5rem;
opacity: 0.9;
}
}
// Responsive spacing
@media (max-width: 991px) {
.container {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
// Global portal enhancements for Khmer typography
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Khmer", "Khmer OS", sans-serif;
}
// Ensure Khmer text renders properly
[lang="km"],
.khmer-text {
font-family: "Noto Sans Khmer", "Khmer OS", "Khmer OS System", sans-serif;
line-height: 1.6;
}
+274
View File
@@ -0,0 +1,274 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="cpp_dashboard" name="CPP Election Dashboard">
<t t-call="portal.portal_layout">
<div class="container-fluid mt-4">
<!-- Auto-refresh meta tag -->
<meta http-equiv="refresh" content="3600"/>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card bg-primary text-white shadow-sm">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-md-8">
<h3 class="mb-0 fw-bold">
<i class="fa fa-chart-line me-2"/>
លទ្ធផលដំណើរការបោះឆ្នោត
</h3>
<small>Dashboard - Election Process Results</small>
</div>
<div class="col-md-4 text-md-end">
<h4 class="mb-0 fw-bold">
<i class="fa fa-clock me-2"/>
ម៉ោង: <t t-esc="current_dt.strftime('%H:%M')"/>
</h4>
<small>ថ្ងៃ: <t t-esc="current_dt.strftime('%d/%m/%Y')"/></small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ✅ UPDATED FILTERS SECTION -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<form method="get" action="/my/cpp_entries/dashboard" class="row g-2">
<!-- 1. Province Filter -->
<div class="col-md-2">
<label class="form-label fw-bold small">រាជធានី/ខេត្ដ</label>
<select name="province_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="provinces" t-as="prov">
<option t-att-value="prov.id" t-att-selected="sel_province == str(prov.id)">
<t t-esc="prov.location_name"/>
</option>
</t>
</select>
</div>
<!-- 2. District Filter -->
<div class="col-md-2">
<label class="form-label fw-bold small">ស្រុក/ខណ្ឌ</label>
<select name="district_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="districts" t-as="dist">
<option t-att-value="dist.id" t-att-selected="sel_district == str(dist.id)">
<t t-esc="dist.location_name"/>
</option>
</t>
</select>
</div>
<!-- 3. Commune Filter -->
<div class="col-md-2">
<label class="form-label fw-bold small">ឃុំ/សង្កាត់</label>
<select name="commune_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="communes" t-as="com">
<option t-att-value="com.id" t-att-selected="sel_commune == str(com.id)">
<t t-esc="com.location_name"/>
</option>
</t>
</select>
</div>
<!-- 4. Al Office Filter (Text Input) -->
<div class="col-md-3">
<label class="form-label fw-bold small">ការិយាល័យបោះឆ្នោត</label>
<input type="text" name="al_office" class="form-control form-control-sm"
placeholder="ស្វែងរកការិយាល័យ..."
t-att-value="sel_al_office or ''"/>
</div>
<!-- 5. Company Filter -->
<div class="col-md-2">
<label class="form-label fw-bold small">ក្រុមហ៊ុន</label>
<select name="company_id" class="form-select form-select-sm">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="companies" t-as="comp">
<option t-att-value="comp.id" t-att-selected="sel_company == str(comp.id)">
<t t-esc="comp.name"/>
</option>
</t>
</select>
</div>
<!-- 6. Action Buttons -->
<div class="col-md-1 d-flex align-items-end">
<button type="submit" class="btn btn-primary btn-sm w-100" title="តម្រង">
<i class="fa fa-filter"/>
</button>
</div>
<!-- Reset Button (Full width below or separate) -->
<div class="col-12 mt-2 text-end">
<a href="/my/cpp_entries/dashboard" class="btn btn-outline-secondary btn-sm">
<i class="fa fa-refresh me-1"/> រីផ្រេស (Reset)
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Overall Statistics Cards -->
<div class="row mb-4">
<div class="col-12">
<h4 class="text-primary mb-3">
<i class="fa fa-chart-pie me-2"/> លទ្ធផលទូទាំងស្រុក
</h4>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white h-100 shadow-sm">
<div class="card-body text-center">
<h6 class="card-title">ចំនួនអ្នកបោះឆ្នោតសរុប</h6>
<h2 class="display-6 fw-bold"><t t-esc="totals['voters']"/></h2>
<p class="mb-0 small">នាក់</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-success text-white h-100 shadow-sm">
<div class="card-body text-center">
<h6 class="card-title">បោះឆ្នោតរួច</h6>
<h2 class="display-6 fw-bold"><t t-esc="totals['voted']"/></h2>
<p class="mb-0 small"><t t-esc="'%.2f' % overall_pct"/>%</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-warning text-dark h-100 shadow-sm">
<div class="card-body text-center">
<h6 class="card-title">សមាជិក</h6>
<h3 class="fw-bold"><t t-esc="totals['members_voted']"/> / <t t-esc="totals['members']"/></h3>
<p class="mb-0 small"><t t-esc="'%.2f' % totals.get('members_pct', 0)"/>%</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-danger text-white h-100 shadow-sm">
<div class="card-body text-center">
<h6 class="card-title">មិនមែនសមាជិក</h6>
<h3 class="fw-bold"><t t-esc="totals['non_members_voted']"/> / <t t-esc="totals['non_members']"/></h3>
<p class="mb-0 small"><t t-esc="'%.2f' % totals.get('non_members_pct', 0)"/>%</p>
</div>
</div>
</div>
</div>
<!-- Hourly Progress Table -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fa fa-clock me-2"/> តារាងម៉ោងបោះឆ្នោត</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0 text-center align-middle">
<thead class="table-light">
<tr>
<th class="text-start ps-3">ឃុំ/សង្កាត់</th>
<th t-foreach="hours" t-as="hour" class="text-center"><t t-esc="hour"/>:00</th>
<th class="text-center bg-warning text-dark">សរុប</th>
<th class="text-center bg-primary text-white">ភាគរយ</th>
</tr>
</thead>
<tbody>
<t t-foreach="commune_data.items()" t-as="commune">
<tr>
<td class="text-start fw-bold ps-3"><t t-esc="commune[0]"/></td>
<t t-foreach="hours" t-as="hour">
<td>
<t t-if="commune[1]['hourly'][hour]['count'] > 0">
<span class="badge bg-success"><t t-esc="commune[1]['hourly'][hour]['count']"/></span>
</t>
<t t-else="">-</t>
</td>
</t>
<td class="fw-bold"><t t-esc="commune[1]['voted']"/> / <t t-esc="commune[1]['total']"/></td>
<td><span class="badge bg-primary"><t t-esc="'%.2f' % commune[1]['pct']"/>%</span></td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Detailed Statistics Table -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="fa fa-table me-2"/> តារាងស្ថិតិលម្អិត</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-hover mb-0 text-center align-middle">
<thead class="table-light">
<tr>
<th rowspan="2" class="align-middle">ល.រ</th>
<th rowspan="2" class="align-middle text-start ps-3">ឃុំ/សង្កាត់</th>
<th colspan="3" class="bg-warning bg-opacity-25">សមាជិកគណបក្ស</th>
<th colspan="3" class="bg-danger bg-opacity-25">មិនមែនសមាជិក</th>
<th rowspan="2" class="align-middle">សរុប</th>
<th rowspan="2" class="align-middle">ភាគរយ</th>
</tr>
<tr>
<th>សរុប</th><th>បោះឆ្នោតរួច</th><th>%</th>
<th>សរុប</th><th>បោះឆ្នោតរួច</th><th>%</th>
</tr>
</thead>
<tbody>
<t t-foreach="commune_data.items()" t-as="commune">
<tr>
<td><t t-esc="commune_index + 1"/></td>
<td class="text-start fw-bold ps-3"><t t-esc="commune[0]"/></td>
<td><t t-esc="commune[1]['members']"/></td>
<td class="text-success fw-bold"><t t-esc="commune[1]['members_voted']"/></td>
<td><t t-esc="'%.2f' % commune[1]['members_pct']"/>%</td>
<td><t t-esc="commune[1]['non_members']"/></td>
<td class="text-success fw-bold"><t t-esc="commune[1]['non_members_voted']"/></td>
<td><t t-esc="'%.2f' % commune[1]['non_members_pct']"/>%</td>
<td class="fw-bold"><t t-esc="commune[1]['voted']"/> / <t t-esc="commune[1]['total']"/></td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar bg-success" t-attf-style="width: {{commune[1]['pct']}}%">
<t t-esc="'%.1f' % commune[1]['pct']"/>%
</div>
</div>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Footer Info -->
<div class="row">
<div class="col-12">
<div class="alert alert-light border text-center small">
<i class="fa fa-info-circle text-primary me-1"/>
ទិន្នន័យនឹងធ្វើបចចុប្បន្នភាពដោយស្វ័យប្រវត្តិរងរាល់ ១ ម៉ោងម្តង |
ព័ត៌មានចុងក្រោយ: <t t-esc="current_dt.strftime('%d/%m/%Y %H:%M:%S')"/>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="id_party_views" model="ir.ui.view">
<field name="name">Party View</field>
<field name="model">person.member</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="name"/>
<field name="short_name"/>
</list>
</field>
</record>
<record model="ir.actions.act_window" id="party_name_action">
<field name="name">ឈ្មោះគណបក្ស</field>
<field name="res_model">person.member</field>
<field name="view_mode">list</field>
</record>
<menuitem name="Party Name" id="party_name_menu" action="party_name_action" parent="youth_and_scholarship.cpp_setting"/>
</odoo>
+193
View File
@@ -0,0 +1,193 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="party_list" name="Party Voter Statistics">
<t t-call="portal.portal_layout">
<div class="container-fluid mt-4">
<!-- Auto-refresh -->
<meta http-equiv="refresh" content="3600"/>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card bg-primary text-white shadow-sm">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-md-8">
<h3 class="mb-0 fw-bold">
<i class="fa fa-users me-2"/>
ទម្រង់បញ្ជីតាមការវិភាគលើគណបក្សនយោបាយ
</h3>
<small>(មានប្រភេទសមាជិកគណបក្សនយោបាយ)</small>
</div>
<div class="col-md-4 text-md-end">
<h4 class="mb-0 fw-bold">
<i class="fa fa-clock me-2"/>
ម៉ោង: <t t-esc="current_dt.strftime('%H:%M')"/>
</h4>
<small>ថ្ងៃ: <t t-esc="current_dt.strftime('%d/%m/%Y')"/></small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<form method="get" action="/my/cpp_entries/party_list" class="row g-3">
<div class="col-md-4">
<label class="form-label fw-bold">ស្រុក/ខណ្ឌ</label>
<select name="district_id" class="form-select" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="districts" t-as="district">
<option t-att-value="district.id" t-att-selected="sel_district == str(district.id)">
<t t-esc="district.location_name"/>
</option>
</t>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">ឃុំ/សង្កាត់</label>
<select name="commune_id" class="form-select" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="communes" t-as="commune">
<option t-att-value="commune.id" t-att-selected="sel_commune == str(commune.id)">
<t t-esc="commune.location_name"/>
</option>
</t>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<a href="/my/cpp_entries/party_list" class="btn btn-outline-primary w-100">
<i class="fa fa-refresh me-1"/> រីផ្រេស
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Info Box -->
<div class="alert alert-info mb-4">
<i class="fa fa-info-circle me-2"/>
<strong>ការព្រមាន:</strong> បញ្ជីនេះបង្ហាញពីចំនួនអ្នកបោះឆ្នោតតាមគណបក្សនយោបាយ។
ចុចលើឈ្មោះគណបក្ស ដើម្បីមើលបញ្ជីឈ្មោះអ្នកបោះឆ្នោតនីមួយៗ។
</div>
<!-- Party Statistics Table -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="fa fa-chart-bar me-2"/>
ទម្រង់បញ្ជីលទ្ធផលរាប់សន្លឹកឆ្នោត
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-hover mb-0">
<thead class="table-light">
<tr>
<th class="text-center" style="width: 10%;">ល.រ.</th>
<th class="text-center" style="width: 60%;">ឈ្មោះគណបក្សនយោបាយ</th>
<th class="text-center" style="width: 15%;">សម្គាល់</th>
<th class="text-center" style="width: 15%;">សកម្មភាព</th>
</tr>
</thead>
<tbody>
<t t-foreach="party_stats.items()" t-as="party">
<tr class="table-hover" style="cursor: pointer;">
<td class="text-center fw-bold"><t t-esc="party_index + 1"/></td>
<td class="fw-bold text-primary">
<a t-attf-href="/my/cpp_entries/party_voters/#{party[0]}"
class="text-decoration-none"
style="color: inherit;">
<i class="fa fa-users me-2"/>
<t t-esc="party[1]['name']"/>
</a>
</td>
<td class="text-center">
<span class="badge bg-primary fs-6">
<t t-esc="party[1]['total']"/> នាក់
</span>
</td>
<td class="text-center">
<a t-attf-href="/my/cpp_entries/party_voters/#{party[0]}"
class="btn btn-sm btn-primary">
<i class="fa fa-eye"/> មើលលម្អិត
</a>
</td>
</tr>
</t>
<tr class="table-primary fw-bold">
<td class="text-center" colspan="2">សរុបទាំងអស់</td>
<td class="text-center">
<span class="badge bg-success fs-6">
<t t-esc="total_voters"/> នាក់
</span>
</td>
<td/>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<t t-foreach="party_stats.items()" t-as="party">
<div class="col-md-4 mb-3">
<div class="card h-100 shadow-sm border-primary">
<div class="card-header bg-primary text-white">
<h6 class="mb-0"><t t-esc="party[1]['name']"/></h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-2">
<h4 class="text-primary"><t t-esc="party[1]['total']"/></h4>
<small class="text-muted">សរុប</small>
</div>
<div class="col-6 mb-2">
<h4 class="text-success"><t t-esc="party[1]['voted']"/></h4>
<small class="text-muted">បានបោះឆ្នោត</small>
</div>
<div class="col-6">
<small>ប្រុស: <strong><t t-esc="party[1]['male']"/></strong></small>
</div>
<div class="col-6">
<small>ស្រី: <strong><t t-esc="party[1]['female']"/></strong></small>
</div>
</div>
</div>
<div class="card-footer bg-white">
<a t-attf-href="/my/cpp_entries/party_voters/#{party[0]}"
class="btn btn-sm btn-outline-primary w-100">
<i class="fa fa-list me-1"/> មើលបញ្ជី
</a>
</div>
</div>
</div>
</t>
</div>
<!-- Back to Dashboard -->
<div class="row">
<div class="col-12">
<a href="/my/cpp_entries/dashboard" class="btn btn-secondary">
<i class="fa fa-arrow-left me-1"/> ត្រឡប់ទៅ Dashboard
</a>
</div>
</div>
</div>
</t>
</template>
</odoo>
+200
View File
@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="party_voters_detail" name="Party Voters Detail">
<t t-call="portal.portal_layout">
<div class="container-fluid mt-4">
<!-- Auto-refresh -->
<meta http-equiv="refresh" content="3600"/>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="card bg-success text-white shadow-sm">
<div class="card-body py-3">
<div class="row align-items-center">
<div class="col-md-8">
<h3 class="mb-0 fw-bold">
<i class="fa fa-users me-2"/>
<t t-esc="party_name"/>
</h3>
<small>បញ្ជី្មោះអនកបោះឆនោត</small>
</div>
<div class="col-md-4 text-md-end">
<h4 class="mb-0 fw-bold">
<i class="fa fa-clock me-2"/>
ម៉ោង: <t t-esc="current_dt.strftime('%H:%M')"/>
</h4>
<small>ថ្ងៃ: <t t-esc="current_dt.strftime('%d/%m/%Y')"/></small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<form method="get" t-attf-action="/my/cpp_entries/party_voters/#{party_key}" class="row g-3">
<div class="col-md-4">
<label class="form-label fw-bold">ស្រុក/ខណ្ឌ</label>
<select name="district_id" class="form-select" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="districts" t-as="district">
<option t-att-value="district.id" t-att-selected="sel_district == str(district.id)">
<t t-esc="district.location_name"/>
</option>
</t>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">ឃុំ/សង្កាត់</label>
<select name="commune_id" class="form-select" onchange="this.form.submit()">
<option value="">-- ទាំងអស់ --</option>
<t t-foreach="communes" t-as="commune">
<option t-att-value="commune.id" t-att-selected="sel_commune == str(commune.id)">
<t t-esc="commune.location_name"/>
</option>
</t>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<a t-attf-href="/my/cpp_entries/party_voters/#{party_key}" class="btn btn-outline-primary w-100">
<i class="fa fa-refresh me-1"/> រីផ្រេស
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Statistics Summary -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card bg-primary text-white h-100">
<div class="card-body text-center">
<h5>សរុប</h5>
<h2 class="display-4 fw-bold"><t t-esc="total"/></h2>
<small>នាក់</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-success text-white h-100">
<div class="card-body text-center">
<h5>បានបោះឆ្ោត</h5>
<h2 class="display-4 fw-bold"><t t-esc="voted"/></h2>
<small><t t-esc="'%.1f' % (voted/total*100 if total > 0 else 0)"/>%</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-info text-white h-100">
<div class="card-body text-center">
<h5>ប្រុស</h5>
<h2 class="display-4 fw-bold"><t t-esc="male"/></h2>
<small>នាក់</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card bg-danger text-white h-100">
<div class="card-body text-center">
<h5>ស្រី</h5>
<h2 class="display-4 fw-bold"><t t-esc="female"/></h2>
<small>នាក់</small>
</div>
</div>
</div>
</div>
<!-- Voters List Table -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fa fa-list me-2"/>
បញ្ជីឈ្មោះអ្នកបោះឆ្នោត
</h5>
<span class="badge bg-light text-primary">
<t t-esc="total"/> នាក់
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0">
<thead class="table-light">
<tr>
<th class="text-center">ល.រ.</th>
<th>ឈ្មោះ</th>
<th class="text-center">ភេទ</th>
<th>ថ្ងៃខែឆ្នាំកំណើត</th>
<th>លេខទូរស័ព្ទ</th>
<th>ឃុំ/សង្កាត់</th>
<th>ស្រុក/ខណ្ឌ</th>
<th class="text-center">ស្ថានភាព</th>
</tr>
</thead>
<tbody>
<t t-foreach="voters_list" t-as="voter">
<tr>
<td class="text-center"><t t-esc="voter_index + 1"/></td>
<td class="fw-bold"><t t-esc="voter['name']"/></td>
<td class="text-center">
<t t-if="voter['gender'] == 'ប្រុស'">
<span class="badge bg-info"><t t-esc="voter['gender']"/></span>
</t>
<t t-else="">
<span class="badge bg-danger"><t t-esc="voter['gender']"/></span>
</t>
</td>
<td><t t-esc="voter['dob'] or '-'"/></td>
<td><t t-esc="voter['phone'] or ' '"/></td>
<td><t t-esc="voter['commune']"/></td>
<td><t t-esc="voter['district']"/></td>
<td class="text-center">
<t t-if="voter['status_vote']">
<span class="badge bg-success">បានបោះឆ្នោត</span>
</t>
<t t-else="">
<span class="badge bg-warning text-dark">មិនទាន់</span>
</t>
</td>
</tr>
</t>
<t t-if="not voters_list">
<tr>
<td colspan="8" class="text-center text-muted py-4">
<i class="fa fa-info-circle me-2"/>
មិនមានទិន្ននយ
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation Buttons -->
<div class="row">
<div class="col-12">
<a href="/my/cpp_entries/party_list" class="btn btn-secondary me-2">
<i class="fa fa-arrow-left me-1"/> ត្រឡប់ទៅបញ្ជីគណបក្ស
</a>
<a href="/my/cpp_entries/dashboard" class="btn btn-outline-primary">
<i class="fa fa-chart-line me-1"/> ទៅ Dashboard
</a>
</div>
</div>
</div>
</t>
</template>
</odoo>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add CPP Entries link to portal home dashboard -->
<template id="portal_my_home_cpp" inherit_id="portal.portal_my_home" name="CPP Entries Link" priority="30">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">CPP Entries</t>
<t t-set="url" t-value="'/my/cpp_entries'"/>
<t t-set="placeholder_count" t-value="'cpp_entries_count'"/>
<t t-set="icon" t-value="'fa fa-list-alt'"/>
</t>
</xpath>
</template>
</odoo>
+434
View File
@@ -0,0 +1,434 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- =======================================================
TEMPLATE: List of CPP Entries
======================================================= -->
<template id="portal_my_cpp_entries" name="My CPP Entries">
<t t-call="portal.portal_layout">
<div class="container mt-4 khmer-text">
<!-- Error Alert -->
<t t-if="error">
<div class="alert alert-danger alert-dismissible fade show">
<i class="fa fa-exclamation-triangle me-2"/>
<strong>Error:</strong> <t t-esc="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fa fa-list-alt me-2"/>បញ្ជី</h2>
<a href="/my/cpp_entries/new" class="btn btn-primary">
<i class="fa fa-plus me-1"/>បង្កើតថ្មី
</a>
</div>
<t t-if="entries">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>ថ្ងៃបង្កើត</th>
<th>រាជធានី/ខេត្ដ</th>
<th>ស្រុក/ខណ្ឌ</th>
<th>ឃុំ/សង្កាត់</th>
<th>ការិយាល័យបោះឆ្នោត</th>
<th class="text-center">អ្នកបោះឆ្នោត</th>
<th class="text-center">មិនទាន់បោះឆ្នោត</th>
<th class="text-end">សកម្មភាព</th>
</tr>
</thead>
<tbody>
<t t-foreach="entries" t-as="entry">
<tr>
<td><t t-esc="entry.create_date.strftime('%d/%m/%Y') if entry.create_date else '-'"/></td>
<td><t t-esc="entry.province_id.location_name or '-'"/></td>
<td><t t-esc="entry.district_id.location_name or '-'"/></td>
<td><t t-esc="entry.commune_id.location_name or '-'"/></td>
<td><t t-esc="entry.al_office or '-'"/></td>
<td class="text-center"><span class="badge bg-success"><t t-esc="entry.voter_count or 0"/></span></td>
<td class="text-center"><span class="badge bg-warning text-dark"><t t-esc="entry.non_voter_count or 0"/></span></td>
<td class="text-end">
<a t-attf-href="/my/cpp_entries/#{entry.id}" class="btn btn-sm btn-outline-primary">
<i class="fa fa-eye"/> មើល
</a>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-else="">
<div class="alert alert-info text-center">
<i class="fa fa-info-circle me-2"/>
មិនទាន់មានទិន្នន័យ។ <a href="/my/cpp_entries/new">ចុចទីនេះ</a> ដើម្បីបង្កើតថ្មី។
</div>
</t>
</div>
</t>
</template>
<!-- =======================================================
TEMPLATE: Main CPP Entry Form
======================================================= -->
<template id="portal_cpp_form" name="CPP Entry Form">
<t t-call="portal.portal_layout">
<div class="container mt-4 o_portal_cpp_form">
<!-- Error Alert -->
<t t-if="error">
<div class="alert alert-danger alert-dismissible fade show">
<i class="fa fa-exclamation-triangle me-2"/>
<strong>Error:</strong> <t t-esc="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<h2 class="mb-4 text-primary">
<i class="fa fa-edit me-2"/>
<t t-if="entry.id">កែបញ្ជីឈ្មោះ</t>
<t t-else="">បង្កើតបញ្ជីឈ្មោះថ្មី</t>
</h2>
<form t-attf-action="/my/cpp_entries/submit" method="post" class="bg-white p-4 rounded shadow-sm border">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="entry_id" t-att-value="entry.id or 0"/>
<!-- Location Section -->
<div class="row mb-4 p-3 bg-light rounded">
<div class="col-12 mb-2"><h5 class="fw-bold text-secondary">📍 ព័ត៌មានទីតាំង</h5></div>
<!-- Province -->
<div class="col-md-4 mb-3">
<label class="form-label fw-bold">រាជធានី/ខេត្ដ <span class="text-danger">*</span></label>
<select name="province_id" class="form-select" required="1" id="province_select">
<option value="">-- ជ្រើសរើសរាជធានី/ខេត្ដ --</option>
<t t-foreach="provinces" t-as="prov">
<option t-att-value="prov.id" t-att-selected="entry.province_id.id == prov.id">
<t t-esc="prov.location_name"/>
</option>
</t>
</select>
</div>
<!-- District -->
<div class="col-md-4 mb-3">
<label class="form-label fw-bold">ស្រុក/ខណ្ឌ</label>
<select name="district_id" class="form-select" id="district_select">
<option value="">-- ជ្រើសរើសស្រុក/ខណ្ឌ --</option>
<t t-if="entry.district_id">
<option t-att-value="entry.district_id.id" selected="selected">
<t t-esc="entry.district_id.location_name"/>
</option>
</t>
</select>
</div>
<!-- Commune -->
<div class="col-md-4 mb-3">
<label class="form-label fw-bold">ឃុំ/សង្កាត់</label>
<select name="commune_id" class="form-select" id="commune_select">
<option value="">-- ជ្រើសរើសឃុំ/សង្កាត់ --</option>
<t t-if="entry.commune_id">
<option t-att-value="entry.commune_id.id" selected="selected">
<t t-esc="entry.commune_id.location_name"/>
</option>
</t>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-bold">លេខការិយាល័យ</label>
<input name="al_office" class="form-control form-control-lg" placeholder="លេខការិយាល័យ" t-att-value="entry.al_office or ''"/>
</div>
</div>
<!-- ✅ CONDITIONAL: Only show tables/buttons IF Entry is Saved -->
<t t-if="entry.id">
<!-- Table 1: Non-Voters List (Pink/Red) -->
<div class="card mb-4 border-danger">
<div class="card-header bg-danger text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">📋 បញ្ជីឈ្មោះអ្នកមិនទាន់បោះឆ្នោត</h5>
<span class="badge bg-light text-danger fs-6">
<t t-esc="len(entry.info_ids.filtered(lambda r: not r.status_vote))"/> នាក់
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:5%" class="text-center">ល.រ.</th>
<th style="width:8%">ភេទ</th>
<th style="width:20%">ឈ្មោះ</th>
<th style="width:12%">ថ្ងៃកំណើត</th>
<th style="width:25%">អាសយដ្ឋាន</th>
<!-- <th style="width:10%" class="text-center">ទូរសព្ទ</th>-->
<th style="width:10%" class="text-center">ជាសមាជិក</th>
<th style="width:12%" class="text-center">ស្ថានភាព</th>
<th style="width:12%" class="text-center">សកម្មភាព</th>
<th style="width:8%" class="text-center">រូប</th>
</tr>
</thead>
<tbody>
<t t-if="entry.info_ids.filtered(lambda r: not r.status_vote)">
<t t-foreach="entry.info_ids.filtered(lambda r: not r.status_vote)" t-as="voter">
<tr>
<td class="text-center fw-bold"><t t-esc="voter_index + 1"/></td>
<td>
<span t-if="voter.gender" t-attf-class="badge bg-#{'info' if voter.gender.name == 'ប្រុស' else 'danger'}">
<t t-esc="voter.gender.name or '-'"/>
</span>
<span t-else="" class="text-muted">-</span>
</td>
<td class="fw-medium"><t t-esc="voter.name or ''"/></td>
<td><t t-esc="voter.dob or '-'"/></td>
<td><t t-esc="voter.address or '-'"/></td>
<!-- <td class="text-center"><t t-esc="voter.phone or '-'"/></td>-->
<td><t t-esc="voter.cpp_member or '-'"/> </td>
<td class="text-center">
<span class="badge bg-warning text-dark">មិនទាន់បោះឆ្នោត</span>
</td>
<td class="text-center">
<!-- ✅ TOGGLE BUTTON: Mark as Voted -->
<a t-attf-href="/my/cpp_entries/voter/#{voter.id}/toggle_vote?csrf_token=#{request.csrf_token()}"
class="btn btn-sm btn-success py-0 me-1"
title="កំណត់ថាបានបោះឆ្នោត">
<i class="fa fa-check"/> បានបោះរួច
</a>
<a t-attf-href="/my/cpp_entries/voter/#{voter.id}/edit" class="btn btn-sm btn-outline-primary py-0 me-1"><i class="fa fa-pencil"/></a>
<!-- <a t-attf-href="/my/cpp_entries/voter/#{voter.id}/delete?csrf_token=#{request.csrf_token()}" class="btn btn-sm btn-outline-danger py-0" onclick="return confirm('តើអ្នកពិតជាចង់លុកឈ្មោះនេះមែនទេ?');"><i class="fa fa-trash"/></a>-->
</td>
<td class="text-center">
<t t-if="voter.photo">
<img t-att-src="image_data_uri(voter.photo)" class="rounded" style="width:40px;height:40px;object-fit:cover;" alt="Photo"/>
</t>
<t t-else=""><span class="text-muted small"></span></t>
</td>
</tr>
</t>
</t>
<t t-else="">
<tr><td colspan="9" class="text-center text-muted py-4"><i class="fa fa-info-circle me-1"/> មិនទាន់មានទិន្នន័យ</td></tr>
</t>
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-white">
<a t-attf-href="/my/cpp_entries/voter/new?entry_id=#{entry.id}&amp;status_vote=0" class="btn btn-success btn-sm">
<i class="fa fa-plus me-1"/> បន្ថែមអ្នកមិនទាន់បោះឆ្នោត
</a>
</div>
</div>
<!-- Table 2: Voters List (Green) -->
<div class="card mb-4 border-success">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">✅ បញ្ជីឈ្មោះអនកបានបោះឆ្នោត</h5>
<span class="badge bg-light text-success fs-6">
<t t-esc="len(entry.info_ids.filtered(lambda r: r.status_vote))"/> នាក់
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:5%" class="text-center">ល.រ.</th>
<th style="width:8%">ភេទ</th>
<th style="width:20%">ឈ្មោះ</th>
<th style="width:12%">ថ្ងៃកំណើត</th>
<th style="width:25%">អាសយដ្ឋាន</th>
<!-- <th style="width:10%" class="text-center">ទូរស័ព្ទ</th>-->
<th style="width:12%" class="text-center">ស្ថានភាព</th>
<th style="width:12%" class="text-center">សកម្មភាព</th>
<th style="width:8%" class="text-center">រូប</th>
</tr>
</thead>
<tbody>
<t t-if="entry.info_ids.filtered(lambda r: r.status_vote)">
<t t-foreach="entry.info_ids.filtered(lambda r: r.status_vote)" t-as="voter">
<tr>
<td class="text-center fw-bold"><t t-esc="voter_index + 1"/></td>
<td>
<span t-if="voter.gender" t-attf-class="badge bg-#{'info' if voter.gender.name == 'ប្រុស' else 'danger'}">
<t t-esc="voter.gender.name or '-'"/>
</span>
<span t-else="" class="text-muted">-</span>
</td>
<td class="fw-medium"><t t-esc="voter.name or ''"/></td>
<td><t t-esc="voter.dob or '-'"/></td>
<td><t t-esc="voter.address or '-'"/></td>
<!-- <td class="text-center"><t t-esc="voter.phone or '-'"/></td>-->
<td class="text-center">
<span class="badge bg-success">បោះឆ្នោតរួច</span>
</td>
<td class="text-center">
<!-- ✅ TOGGLE BUTTON: Mark as Not Voted -->
<a t-attf-href="/my/cpp_entries/voter/#{voter.id}/toggle_vote?csrf_token=#{request.csrf_token()}"
class="btn btn-sm btn-warning py-0 me-1"
title="កំណត់ថាមិនទាន់បោះឆ្នោត">
<i class="fa fa-undo"/> មិនទាន់បោះឆ្នោត
</a>
<a t-attf-href="/my/cpp_entries/voter/#{voter.id}/edit" class="btn btn-sm btn-outline-primary py-0 me-1"><i class="fa fa-pencil"/></a>
<a t-attf-href="/my/cpp_entries/voter/#{voter.id}/delete?csrf_token=#{request.csrf_token()}" class="btn btn-sm btn-outline-danger py-0" onclick="return confirm('តើអ្នកពិតជាចង់លុកឈ្មោះនេះមែនទេ?');"><i class="fa fa-trash"/></a>
</td>
<td class="text-center">
<t t-if="voter.photo">
<img t-att-src="image_data_uri(voter.photo)" class="rounded" style="width:40px;height:40px;object-fit:cover;" alt="Photo"/>
</t>
<t t-else=""><span class="text-muted small"></span></t>
</td>
</tr>
</t>
</t>
<t t-else="">
<tr><td colspan="9" class="text-center text-muted py-4"><i class="fa fa-info-circle me-1"/> មិនទាន់មានទិន្នន័យ</td></tr>
</t>
</tbody>
</table>
</div>
</div>
<!-- <div class="card-footer bg-white">-->
<!-- <a t-attf-href="/my/cpp_entries/voter/new?entry_id=#{entry.id}&amp;status_vote=1" class="btn btn-success btn-sm">-->
<!-- <i class="fa fa-plus me-1"/> បន្ថែមអ្នកបានបោះឆ្នោត-->
<!-- </a>-->
<!-- </div>-->
</div>
</t>
<t t-else="">
<!-- ✅ Info Message if entry is NOT saved yet -->
<div class="alert alert-info">
<i class="fa fa-info-circle me-2"/>
<strong>ចំណាំ:</strong> សូមចុច <b>"រក្សាទុក"</b> ដើម្បីបង្កើតបញ្ជីជាមុនសិន ទើបអាចបន្ថែមអ្នកបោះឆ្នោតបាន។
</div>
</t>
<!-- Action Buttons -->
<div class="d-flex justify-content-between mt-4 pt-3 border-top">
<a href="/my/cpp_entries" class="btn btn-outline-secondary px-4">
<i class="fa fa-arrow-left me-1"/> ត្រប់
</a>
<div>
<button type="submit" class="btn btn-primary px-5">
<i class="fa fa-save me-1"/> រក្សាទុកព័ត៌មាន
</button>
</div>
</div>
</form>
</div>
</t>
</template>
<!-- =======================================================
TEMPLATE: Voter Add/Edit Form
======================================================= -->
<template id="portal_voter_form" name="Voter Information Form">
<t t-call="portal.portal_layout">
<div class="container mt-4" style="max-width: 750px;">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">ផ្ទាំងគ្រប់គ្រង</a></li>
<li class="breadcrumb-item"><a href="/my/cpp_entries">CPP Entries</a></li>
<li class="breadcrumb-item"><a t-attf-href="/my/cpp_entries/#{entry.id}">បញ្ជី</a></li>
<li class="breadcrumb-item active">
<t t-if="voter.id">កែព័ត៌មាន</t>
<t t-else="">បន្ថែមថ្មី</t>
</li>
</ol>
</nav>
<div class="card shadow-sm border-0">
<div class="card-header bg-primary text-white">
<h4 class="mb-0"><i class="fa fa-user me-2"/>
<t t-if="voter.id">កែព័ត៌មានអ្នកបោះឆ្នោត</t>
<t t-else="">បន្ថែមអ្នកបោះឆ្នោតថ្មី</t>
</h4>
</div>
<form t-attf-action="/my/cpp_entries/voter/submit" method="post" enctype="multipart/form-data" class="card-body">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- ✅ Hidden Fields for ID tracking -->
<input type="hidden" name="entry_id" t-att-value="entry.id"/>
<input type="hidden" name="voter_id" t-att-value="voter.id or 0"/>
<!-- Photo Upload Section -->
<div class="photo-upload-container text-center mb-4">
<div class="position-relative d-inline-block">
<!-- ✅ Show existing photo if available -->
<t t-if="voter.photo">
<img t-att-src="image_data_uri(voter.photo)"
id="photo_preview"
class="rounded-circle border border-3 border-light shadow"
style="width:120px;height:120px;object-fit:cover;display:block;"
alt="Profile Photo"/>
</t>
<!-- ✅ Show placeholder if no photo -->
<t t-else="">
<div class="photo-placeholder rounded-circle bg-light d-flex align-items-center justify-content-center border border-3 border-light shadow"
style="width:120px;height:120px;">
<i class="fa fa-user fa-3x text-muted"/>
</div>
</t>
<!-- ✅ Camera button with file input -->
<label for="photo_upload"
class="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle shadow"
style="width:36px;height:36px;padding:0;line-height:36px;text-align:center;cursor:pointer;z-index:10;">
<i class="fa fa-camera" style="font-size:14px;"/>
<input type="file"
name="photo"
id="photo_upload"
class="d-none"
accept="image/*"/>
</label>
</div>
<small class="text-muted d-block mt-2">រូបថត (JPEG, PNG - អតិបរមា 2MB)</small>
</div>
<!-- Personal Info -->
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">ឈ្មោះ <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control form-control-lg" required="1" placeholder="បញ្ចូល្មោះពេញ" t-att-value="voter.name or ''"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">ភេទ</label>
<select name="gender" class="form-select form-select-lg">
<option value="">-- ជ្រើសរើសភេទ --</option>
<t t-foreach="genders" t-as="g">
<option t-att-value="g.id" t-att-selected="voter.gender.id == g.id"><t t-esc="g.name"/></option>
</t>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">ថ្ងៃខែឆ្នាំកំណើត</label>
<input type="date" name="dob" class="form-control form-control-lg" t-att-value="voter.dob"/>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-bold">លេខទូរស័ព្ទ</label>
<input type="tel" name="phone" class="form-control form-control-lg" placeholder="012 345 678" t-att-value="voter.phone or ''"/>
</div>
<div class="col-12 mb-3">
<label class="form-label fw-bold">អាសយដ្ឋាន</label>
<textarea name="address" class="form-control" rows="3" placeholder="បញ្ចូលអាសយដ្ឋានពេញ" t-esc="voter.address or ''"/>
</div>
<div class="col-12 mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="status_vote" id="status_vote" t-att-checked="'checked' if status_vote == 1 else None"/>
<label class="form-check-label fw-bold" for="status_vote">✅ បោះឆ្នោតរួច</label>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4 pt-3 border-top">
<a t-attf-href="/my/cpp_entries/#{entry.id}" class="btn btn-outline-secondary px-4"><i class="fa fa-times me-1"/> បោះបង់</a>
<button type="submit" class="btn btn-primary px-5">
<i class="fa fa-save me-1"/> <t t-if="voter.id">កែប្រែ</t><t t-else="">បន្ថែម</t>
</button>
</div>
</form>
</div>
</div>
</t>
</template>
</odoo>