Merge branch 'master' of https://github.com/nguyen-ngo/QR_Code_Management
This commit is contained in:
+199
-55
@@ -47,30 +47,53 @@ def extract_street_address(address):
|
|||||||
Extract the core street address (number + street name) from an address string.
|
Extract the core street address (number + street name) from an address string.
|
||||||
This is the most reliable identifier for location matching.
|
This is the most reliable identifier for location matching.
|
||||||
|
|
||||||
|
Handles cases where:
|
||||||
|
- Street number is at the beginning: "735 18th St S"
|
||||||
|
- Building name comes first: "Aurora Hills Library, 735, 18th Street South"
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
address: Normalized address string
|
address: Normalized address string
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Core street address string (e.g., "3402 s glebe rd")
|
Core street address string (e.g., "735 18th st s")
|
||||||
"""
|
"""
|
||||||
if not address:
|
if not address:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Pattern to match: street number + optional directional + street name + street type
|
addr_lower = address.lower()
|
||||||
# Examples: "3402 south glebe road", "7100 gordon rd", "123 n main st"
|
|
||||||
street_pattern = r'^(\d+[-\w]*)\s+([nsew]?\s*[\w\s]+?\s*(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|trail|pike|run|walk|path|loop))'
|
|
||||||
|
|
||||||
match = re.search(street_pattern, address.lower())
|
# Pattern to match: street number + optional directional + street name + street type
|
||||||
|
# This pattern searches ANYWHERE in the string, not just at the beginning
|
||||||
|
# Examples: "735 18th st s", "3402 south glebe road", "7100 gordon rd"
|
||||||
|
street_types = r'(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|trail|pike|run|walk|path|loop|road|street|avenue|drive|lane|court|boulevard|parkway|circle|place|terrace|highway)'
|
||||||
|
|
||||||
|
# Pattern: number + ordinal/street name + optional directional + street type
|
||||||
|
# Handles: "735 18th st s", "735, 18th street south"
|
||||||
|
street_pattern = rf'(\d+)[\s,]+(\d*(?:st|nd|rd|th)?\s*[\w\s]*?{street_types})(?:\s+([nsew]|north|south|east|west))?'
|
||||||
|
|
||||||
|
match = re.search(street_pattern, addr_lower)
|
||||||
if match:
|
if match:
|
||||||
street_num = match.group(1).strip()
|
street_num = match.group(1).strip()
|
||||||
street_name = match.group(2).strip()
|
street_name = match.group(2).strip()
|
||||||
# Clean up extra spaces
|
direction = match.group(3) if match.group(3) else ""
|
||||||
street_name = re.sub(r'\s+', ' ', street_name)
|
|
||||||
return f"{street_num} {street_name}"
|
|
||||||
|
|
||||||
# Fallback: try to extract just number + next few words
|
# Clean up extra spaces and commas
|
||||||
simple_pattern = r'^(\d+[-\w]*)\s+([\w\s]+)'
|
street_name = re.sub(r'[\s,]+', ' ', street_name).strip()
|
||||||
match = re.search(simple_pattern, address.lower())
|
|
||||||
|
# Normalize direction
|
||||||
|
dir_map = {'north': 'n', 'south': 's', 'east': 'e', 'west': 'w'}
|
||||||
|
if direction:
|
||||||
|
direction = dir_map.get(direction, direction)
|
||||||
|
|
||||||
|
result = f"{street_num} {street_name}"
|
||||||
|
if direction:
|
||||||
|
result += f" {direction}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fallback: try to find just a street number followed by some words
|
||||||
|
simple_pattern = r'(\d+)[\s,]+([\w\s]+)'
|
||||||
|
match = re.search(simple_pattern, addr_lower)
|
||||||
if match:
|
if match:
|
||||||
street_num = match.group(1).strip()
|
street_num = match.group(1).strip()
|
||||||
# Take words until we hit something that looks like a city/state
|
# Take words until we hit something that looks like a city/state
|
||||||
@@ -108,6 +131,10 @@ def normalize_address(address):
|
|||||||
"3402 South Glebe Road Arlington VA 22202"
|
"3402 South Glebe Road Arlington VA 22202"
|
||||||
"3402, South Glebe Road, Aurora Hills, Arlington VA 22202"
|
"3402, South Glebe Road, Aurora Hills, Arlington VA 22202"
|
||||||
Both normalize to: "3402 s glebe rd, arlington, va 22202"
|
Both normalize to: "3402 s glebe rd, arlington, va 22202"
|
||||||
|
|
||||||
|
"Aurora Hills Branch Library, 735, 18th Street South, Arlington, VA 22202"
|
||||||
|
"735 18th St S, Arlington, VA 22202"
|
||||||
|
Both normalize to: "735 18th st s, arlington, va 22202"
|
||||||
"""
|
"""
|
||||||
if not address or not isinstance(address, str):
|
if not address or not isinstance(address, str):
|
||||||
return address
|
return address
|
||||||
@@ -119,6 +146,13 @@ def normalize_address(address):
|
|||||||
normalized = re.sub(r'\s+', ' ', normalized) # Multiple spaces to single space
|
normalized = re.sub(r'\s+', ' ', normalized) # Multiple spaces to single space
|
||||||
normalized = re.sub(r'\s*,\s*', ', ', normalized) # Normalize comma spacing
|
normalized = re.sub(r'\s*,\s*', ', ', normalized) # Normalize comma spacing
|
||||||
|
|
||||||
|
# Remove building/location names that come BEFORE the street number
|
||||||
|
# Pattern: remove text before a street number if it looks like a building name
|
||||||
|
# Examples: "Aurora Hills Branch Library, 735" → "735"
|
||||||
|
# "Fire Station #7, 123 Main St" → "123 Main St"
|
||||||
|
building_pattern = r'^[^,\d]*(?:library|station|center|building|plaza|tower|hall|office|school|church|hospital|clinic|bank|hotel|restaurant|store|shop|mall|complex|headquarters|hq|branch)[^,\d]*,\s*'
|
||||||
|
normalized = re.sub(building_pattern, '', normalized, flags=re.IGNORECASE)
|
||||||
|
|
||||||
# Standardize common street abbreviations to short forms
|
# Standardize common street abbreviations to short forms
|
||||||
street_abbrev = {
|
street_abbrev = {
|
||||||
r'\broad\b': 'rd',
|
r'\broad\b': 'rd',
|
||||||
@@ -154,39 +188,134 @@ def normalize_address(address):
|
|||||||
for full_form, abbrev in directionals.items():
|
for full_form, abbrev in directionals.items():
|
||||||
normalized = re.sub(full_form, abbrev, normalized)
|
normalized = re.sub(full_form, abbrev, normalized)
|
||||||
|
|
||||||
|
# Convert full state names to abbreviations
|
||||||
|
state_names = {
|
||||||
|
r'\bvirginia\b': 'va',
|
||||||
|
r'\bmaryland\b': 'md',
|
||||||
|
r'\bdistrict of columbia\b': 'dc',
|
||||||
|
r'\bcalifornia\b': 'ca',
|
||||||
|
r'\bnew york\b': 'ny',
|
||||||
|
r'\btexas\b': 'tx',
|
||||||
|
r'\bflorida\b': 'fl',
|
||||||
|
r'\bpennsylvania\b': 'pa',
|
||||||
|
r'\billinois\b': 'il',
|
||||||
|
r'\bohio\b': 'oh',
|
||||||
|
r'\bgeorgia\b': 'ga',
|
||||||
|
r'\bnorth carolina\b': 'nc',
|
||||||
|
r'\bnew jersey\b': 'nj',
|
||||||
|
r'\bwashington\b': 'wa',
|
||||||
|
r'\bmassachusetts\b': 'ma',
|
||||||
|
r'\barizona\b': 'az',
|
||||||
|
r'\bcolorado\b': 'co',
|
||||||
|
r'\btennessee\b': 'tn',
|
||||||
|
r'\bindiana\b': 'in',
|
||||||
|
r'\bmissouri\b': 'mo',
|
||||||
|
r'\bwisconsin\b': 'wi',
|
||||||
|
r'\bminnesota\b': 'mn',
|
||||||
|
r'\bsouth carolina\b': 'sc',
|
||||||
|
r'\balabama\b': 'al',
|
||||||
|
r'\blouisiana\b': 'la',
|
||||||
|
r'\bkentucky\b': 'ky',
|
||||||
|
r'\boregon\b': 'or',
|
||||||
|
r'\boklahoma\b': 'ok',
|
||||||
|
r'\bconnecticut\b': 'ct',
|
||||||
|
r'\biowa\b': 'ia',
|
||||||
|
r'\bmississippi\b': 'ms',
|
||||||
|
r'\barkansas\b': 'ar',
|
||||||
|
r'\bkansas\b': 'ks',
|
||||||
|
r'\butah\b': 'ut',
|
||||||
|
r'\bnevada\b': 'nv',
|
||||||
|
r'\bnew mexico\b': 'nm',
|
||||||
|
r'\bwest virginia\b': 'wv',
|
||||||
|
r'\bnebraska\b': 'ne',
|
||||||
|
r'\bidaho\b': 'id',
|
||||||
|
r'\bhawaii\b': 'hi',
|
||||||
|
r'\bmaine\b': 'me',
|
||||||
|
r'\bnew hampshire\b': 'nh',
|
||||||
|
r'\brhode island\b': 'ri',
|
||||||
|
r'\bmontana\b': 'mt',
|
||||||
|
r'\bdelaware\b': 'de',
|
||||||
|
r'\bsouth dakota\b': 'sd',
|
||||||
|
r'\bnorth dakota\b': 'nd',
|
||||||
|
r'\balaska\b': 'ak',
|
||||||
|
r'\bvermont\b': 'vt',
|
||||||
|
r'\bwyoming\b': 'wy'
|
||||||
|
}
|
||||||
|
|
||||||
|
for full_name, abbrev in state_names.items():
|
||||||
|
normalized = re.sub(full_name, abbrev, normalized)
|
||||||
|
|
||||||
# Remove neighborhood/district names that aren't essential for location
|
# Remove neighborhood/district names that aren't essential for location
|
||||||
# Examples: "Aurora Hills", "Downtown", etc.
|
# Examples: "Aurora Hills", "Downtown", etc.
|
||||||
parts = [p.strip() for p in normalized.split(',')]
|
parts = [p.strip() for p in normalized.split(',')]
|
||||||
|
|
||||||
# Keep: street address, city, state, zip
|
# Keep: street address, city, state, zip
|
||||||
# Remove: neighborhood names, building names, country suffixes
|
# Remove: neighborhood names, building names, country suffixes, county names
|
||||||
filtered_parts = []
|
filtered_parts = []
|
||||||
|
|
||||||
# Known neighborhood keywords to remove (these don't affect geocoding)
|
# Known neighborhood keywords to remove (these don't affect geocoding)
|
||||||
neighborhood_keywords = ['hills', 'heights', 'park', 'village', 'estates',
|
# NOTE: These should only match if NOT followed by a street type suffix
|
||||||
|
neighborhood_keywords = ['hills', 'heights', 'village', 'estates',
|
||||||
'manor', 'gardens', 'terrace', 'commons', 'plaza',
|
'manor', 'gardens', 'terrace', 'commons', 'plaza',
|
||||||
'downtown', 'midtown', 'uptown', 'district', 'center',
|
'downtown', 'midtown', 'uptown', 'district', 'center',
|
||||||
'crossing', 'corner', 'square', 'point', 'landing',
|
'crossing', 'corner', 'square', 'point', 'landing',
|
||||||
'aurora', 'crystal', 'forest', 'lake', 'river', 'creek',
|
'aurora', 'crystal', 'forest', 'lake', 'river', 'creek',
|
||||||
'meadow', 'valley', 'ridge', 'grove', 'glen', 'woods']
|
'meadow', 'valley', 'ridge', 'grove', 'glen', 'woods',
|
||||||
|
'addison', 'colonial', 'fairfax', 'heritage', 'liberty',
|
||||||
|
'ballston', 'clarendon', 'rosslyn', 'shirlington']
|
||||||
|
|
||||||
|
# Street type suffixes - if a part contains these, it's likely a street address, not a neighborhood
|
||||||
|
street_type_suffixes = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir',
|
||||||
|
'pl', 'ter', 'hwy', 'way', 'road', 'street', 'avenue',
|
||||||
|
'drive', 'lane', 'court', 'boulevard', 'parkway', 'circle',
|
||||||
|
'place', 'terrace', 'highway', 'trail', 'pike', 'run',
|
||||||
|
'walk', 'path', 'loop']
|
||||||
|
|
||||||
|
# Country names and suffixes to remove (English and other languages)
|
||||||
|
country_suffixes = ['usa', 'us', 'united states', 'united states of america',
|
||||||
|
'estados unidos', 'estados unidos de américa', 'estados unidos de america',
|
||||||
|
'eeuu', 'e.u.', 'u.s.a.', 'u.s.', 'america', 'américas']
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
part_clean = part.strip()
|
part_clean = part.strip()
|
||||||
|
|
||||||
# Always keep first part (street address)
|
# Always keep first part (street address) - but only if it contains a number
|
||||||
if i == 0:
|
if i == 0:
|
||||||
filtered_parts.append(part_clean)
|
# Check if this looks like a building name (no street number)
|
||||||
|
if re.search(r'\d', part_clean):
|
||||||
|
filtered_parts.append(part_clean)
|
||||||
|
else:
|
||||||
|
print(f" Removing building name: '{part_clean}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip empty parts
|
# Skip empty parts
|
||||||
if not part_clean:
|
if not part_clean:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip country suffixes
|
# Skip country suffixes (multiple languages)
|
||||||
if part_clean in ['usa', 'us', 'united states']:
|
if part_clean in country_suffixes:
|
||||||
|
print(f" Removing country: '{part_clean}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if it's a neighborhood name (contains neighborhood keywords but no numbers)
|
# Skip county names (e.g., "Arlington County", "Fairfax County")
|
||||||
|
if 'county' in part_clean:
|
||||||
|
print(f" Removing county: '{part_clean}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this part contains a street type suffix - if so, it's a street address, KEEP IT
|
||||||
|
has_street_suffix = False
|
||||||
|
for suffix in street_type_suffixes:
|
||||||
|
# Match word boundary to avoid partial matches (e.g., "dr" in "andra")
|
||||||
|
if re.search(rf'\b{suffix}\b', part_clean):
|
||||||
|
has_street_suffix = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if has_street_suffix:
|
||||||
|
# This is a street address (e.g., "n park dr", "18th st s"), keep it
|
||||||
|
filtered_parts.append(part_clean)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if it's a neighborhood name (contains neighborhood keywords but no numbers and no street suffix)
|
||||||
is_neighborhood = False
|
is_neighborhood = False
|
||||||
for keyword in neighborhood_keywords:
|
for keyword in neighborhood_keywords:
|
||||||
if keyword in part_clean and not re.search(r'\d', part_clean):
|
if keyword in part_clean and not re.search(r'\d', part_clean):
|
||||||
@@ -214,8 +343,8 @@ def normalize_address(address):
|
|||||||
# Reconstruct address
|
# Reconstruct address
|
||||||
normalized = ', '.join(filtered_parts)
|
normalized = ', '.join(filtered_parts)
|
||||||
|
|
||||||
# Remove common country suffixes that don't affect location
|
# Remove common country suffixes that don't affect location (final cleanup)
|
||||||
normalized = re.sub(r',?\s*(usa|united states|us)$', '', normalized)
|
normalized = re.sub(r',?\s*(usa|united states|us|estados unidos.*?|eeuu|u\.s\.a?\.|america|américas?)$', '', normalized, flags=re.IGNORECASE)
|
||||||
|
|
||||||
# Final cleanup: remove trailing commas and spaces
|
# Final cleanup: remove trailing commas and spaces
|
||||||
normalized = normalized.strip(', ')
|
normalized = normalized.strip(', ')
|
||||||
@@ -230,14 +359,16 @@ def normalize_address(address):
|
|||||||
def extract_address_components(address):
|
def extract_address_components(address):
|
||||||
"""
|
"""
|
||||||
Extract key components from an address for comparison.
|
Extract key components from an address for comparison.
|
||||||
|
Handles addresses where the street number may not be at the beginning
|
||||||
|
(e.g., "Aurora Hills Library, 735, 18th Street South")
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
address: Address string (raw or normalized)
|
address: Address string (raw or normalized)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with extracted components:
|
Dictionary with extracted components:
|
||||||
- street_number: The street number (e.g., "3402")
|
- street_number: The street number (e.g., "735")
|
||||||
- street_name: The street name with type (e.g., "s glebe rd")
|
- street_name: The street name with type (e.g., "18th st s")
|
||||||
- city: City name if found
|
- city: City name if found
|
||||||
- state: State abbreviation if found
|
- state: State abbreviation if found
|
||||||
- zip_code: ZIP code if found
|
- zip_code: ZIP code if found
|
||||||
@@ -255,49 +386,62 @@ def extract_address_components(address):
|
|||||||
'zip_code': None
|
'zip_code': None
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract street number (at the beginning)
|
# Extract ZIP code first (most reliable)
|
||||||
street_num_match = re.match(r'^(\d+[-\w]*)', addr_lower)
|
|
||||||
if street_num_match:
|
|
||||||
components['street_number'] = street_num_match.group(1)
|
|
||||||
|
|
||||||
# Extract ZIP code
|
|
||||||
zip_match = re.search(r'\b(\d{5})(?:-\d{4})?\b', addr_lower)
|
zip_match = re.search(r'\b(\d{5})(?:-\d{4})?\b', addr_lower)
|
||||||
if zip_match:
|
if zip_match:
|
||||||
components['zip_code'] = zip_match.group(1)
|
components['zip_code'] = zip_match.group(1)
|
||||||
|
|
||||||
# Extract state (2-letter abbreviation before or after zip)
|
# Extract state (2-letter abbreviation, typically before zip or at end)
|
||||||
state_match = re.search(r'\b([a-z]{2})\s*(?:\d{5}|$)', addr_lower)
|
# Also handle full state names that might not have been normalized
|
||||||
|
valid_states = ['al', 'ak', 'az', 'ar', 'ca', 'co', 'ct', 'de', 'fl', 'ga',
|
||||||
|
'hi', 'id', 'il', 'in', 'ia', 'ks', 'ky', 'la', 'me', 'md',
|
||||||
|
'ma', 'mi', 'mn', 'ms', 'mo', 'mt', 'ne', 'nv', 'nh', 'nj',
|
||||||
|
'nm', 'ny', 'nc', 'nd', 'oh', 'ok', 'or', 'pa', 'ri', 'sc',
|
||||||
|
'sd', 'tn', 'tx', 'ut', 'vt', 'va', 'wa', 'wv', 'wi', 'wy', 'dc']
|
||||||
|
|
||||||
|
state_match = re.search(r'\b([a-z]{2})\s*(?:,?\s*\d{5}|,|$)', addr_lower)
|
||||||
if state_match:
|
if state_match:
|
||||||
potential_state = state_match.group(1)
|
potential_state = state_match.group(1)
|
||||||
# Validate it's a real state abbreviation
|
|
||||||
valid_states = ['al', 'ak', 'az', 'ar', 'ca', 'co', 'ct', 'de', 'fl', 'ga',
|
|
||||||
'hi', 'id', 'il', 'in', 'ia', 'ks', 'ky', 'la', 'me', 'md',
|
|
||||||
'ma', 'mi', 'mn', 'ms', 'mo', 'mt', 'ne', 'nv', 'nh', 'nj',
|
|
||||||
'nm', 'ny', 'nc', 'nd', 'oh', 'ok', 'or', 'pa', 'ri', 'sc',
|
|
||||||
'sd', 'tn', 'tx', 'ut', 'vt', 'va', 'wa', 'wv', 'wi', 'wy', 'dc']
|
|
||||||
if potential_state in valid_states:
|
if potential_state in valid_states:
|
||||||
components['state'] = potential_state
|
components['state'] = potential_state
|
||||||
|
|
||||||
# Extract street name (between number and city/state/zip)
|
# Extract street number - look for it ANYWHERE in the address
|
||||||
# This is the trickiest part
|
# Pattern: standalone number that's likely a street number (not a zip code or ordinal in street name)
|
||||||
if components['street_number']:
|
# Match numbers like "735" or "3402" but not "22202" (zip) or "18th" (ordinal)
|
||||||
# Remove street number from beginning
|
|
||||||
remainder = addr_lower[len(components['street_number']):].strip()
|
|
||||||
remainder = remainder.lstrip(',').strip()
|
|
||||||
|
|
||||||
# Look for street type keywords
|
# First, try to find a number followed by a street-like pattern
|
||||||
street_types = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir',
|
street_num_pattern = r'(?:^|,\s*)(\d{1,5})(?:\s*,\s*|\s+)(\d*(?:st|nd|rd|th)?\s*[\w\s]*?(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|street|road|avenue|drive|lane|court|boulevard))'
|
||||||
'pl', 'ter', 'hwy', 'way', 'trail', 'pike', 'run', 'walk',
|
|
||||||
'path', 'loop', 'road', 'street', 'avenue', 'drive', 'lane',
|
|
||||||
'court', 'boulevard', 'parkway', 'circle', 'place', 'terrace',
|
|
||||||
'highway']
|
|
||||||
|
|
||||||
for st_type in street_types:
|
match = re.search(street_num_pattern, addr_lower)
|
||||||
pattern = rf'^([\w\s]+?\s*{st_type})\b'
|
if match:
|
||||||
match = re.search(pattern, remainder)
|
components['street_number'] = match.group(1)
|
||||||
if match:
|
street_name_raw = match.group(2).strip()
|
||||||
components['street_name'] = match.group(1).strip()
|
# Clean up the street name
|
||||||
break
|
street_name_raw = re.sub(r'[\s,]+', ' ', street_name_raw)
|
||||||
|
components['street_name'] = street_name_raw
|
||||||
|
else:
|
||||||
|
# Fallback: try simpler pattern - just find a number at the start or after comma
|
||||||
|
simple_num_match = re.search(r'(?:^|,\s*)(\d{1,5})(?:\s*,|\s+)(?!\d{4,5}\b)', addr_lower)
|
||||||
|
if simple_num_match:
|
||||||
|
components['street_number'] = simple_num_match.group(1)
|
||||||
|
|
||||||
|
# Try to extract street name after the number
|
||||||
|
remainder = addr_lower[simple_num_match.end():]
|
||||||
|
remainder = remainder.lstrip(', ')
|
||||||
|
|
||||||
|
# Look for street type keywords
|
||||||
|
street_types = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir',
|
||||||
|
'pl', 'ter', 'hwy', 'way', 'trail', 'pike', 'run', 'walk',
|
||||||
|
'path', 'loop', 'road', 'street', 'avenue', 'drive', 'lane',
|
||||||
|
'court', 'boulevard', 'parkway', 'circle', 'place', 'terrace',
|
||||||
|
'highway']
|
||||||
|
|
||||||
|
for st_type in street_types:
|
||||||
|
pattern = rf'^([\w\s]+?\s*{st_type})\b'
|
||||||
|
st_match = re.search(pattern, remainder)
|
||||||
|
if st_match:
|
||||||
|
components['street_name'] = st_match.group(1).strip()
|
||||||
|
break
|
||||||
|
|
||||||
return components
|
return components
|
||||||
|
|
||||||
|
|||||||
@@ -5348,9 +5348,12 @@ def verification_review():
|
|||||||
status_filter = request.args.get('status', 'pending')
|
status_filter = request.args.get('status', 'pending')
|
||||||
date_from = request.args.get('date_from', '')
|
date_from = request.args.get('date_from', '')
|
||||||
date_to = request.args.get('date_to', '')
|
date_to = request.args.get('date_to', '')
|
||||||
|
project_filter = request.args.get('project', '')
|
||||||
|
location_filter = request.args.get('location', '')
|
||||||
|
employee_filter = request.args.get('employee', '')
|
||||||
|
|
||||||
# Build query
|
# Build query - join with QRCode to access project_id
|
||||||
query = AttendanceData.query.filter(
|
query = AttendanceData.query.join(QRCode).filter(
|
||||||
AttendanceData.verification_required == True
|
AttendanceData.verification_required == True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5363,11 +5366,54 @@ def verification_review():
|
|||||||
if date_to:
|
if date_to:
|
||||||
query = query.filter(AttendanceData.check_in_date <= date_to)
|
query = query.filter(AttendanceData.check_in_date <= date_to)
|
||||||
|
|
||||||
|
# Apply project filter
|
||||||
|
if project_filter:
|
||||||
|
try:
|
||||||
|
query = query.filter(QRCode.project_id == int(project_filter))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply location filter
|
||||||
|
if location_filter:
|
||||||
|
query = query.filter(AttendanceData.location_name.ilike(f'%{location_filter}%'))
|
||||||
|
|
||||||
|
# Apply employee ID filter
|
||||||
|
if employee_filter:
|
||||||
|
query = query.filter(AttendanceData.employee_id.ilike(f'%{employee_filter}%'))
|
||||||
|
|
||||||
# Get records with QR code information
|
# Get records with QR code information
|
||||||
verifications = query.join(QRCode).order_by(
|
verifications = query.order_by(
|
||||||
AttendanceData.verification_timestamp.desc()
|
AttendanceData.verification_timestamp.desc()
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Build a dictionary for employee names lookup
|
||||||
|
employee_names = {}
|
||||||
|
for record in verifications:
|
||||||
|
if record.employee_id and record.employee_id not in employee_names:
|
||||||
|
try:
|
||||||
|
employee = Employee.query.filter_by(id=int(record.employee_id)).first()
|
||||||
|
if employee:
|
||||||
|
employee_names[record.employee_id] = f"{employee.lastName}, {employee.firstName}"
|
||||||
|
else:
|
||||||
|
employee_names[record.employee_id] = None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
employee_names[record.employee_id] = None
|
||||||
|
|
||||||
|
# Build a dictionary for project names lookup
|
||||||
|
project_names = {}
|
||||||
|
for record in verifications:
|
||||||
|
if record.qr_code and record.qr_code.project_id:
|
||||||
|
project_id = record.qr_code.project_id
|
||||||
|
if project_id not in project_names:
|
||||||
|
try:
|
||||||
|
project = Project.query.get(project_id)
|
||||||
|
if project:
|
||||||
|
project_names[project_id] = project.name
|
||||||
|
else:
|
||||||
|
project_names[project_id] = None
|
||||||
|
except Exception:
|
||||||
|
project_names[project_id] = None
|
||||||
|
|
||||||
# Get counts for status badges
|
# Get counts for status badges
|
||||||
pending_count = AttendanceData.query.filter(
|
pending_count = AttendanceData.query.filter(
|
||||||
AttendanceData.verification_status == 'pending'
|
AttendanceData.verification_status == 'pending'
|
||||||
@@ -5381,6 +5427,20 @@ def verification_review():
|
|||||||
AttendanceData.verification_status == 'rejected'
|
AttendanceData.verification_status == 'rejected'
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
# Get all projects for filter dropdown
|
||||||
|
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||||
|
|
||||||
|
# Get unique locations for filter dropdown
|
||||||
|
locations = db.session.query(AttendanceData.location_name).filter(
|
||||||
|
AttendanceData.verification_required == True
|
||||||
|
).distinct().order_by(AttendanceData.location_name).all()
|
||||||
|
location_list = [loc[0] for loc in locations if loc[0]]
|
||||||
|
|
||||||
|
# Log access
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"User {session.get('username')} ({session.get('role')}) accessed verification review page"
|
||||||
|
)
|
||||||
|
|
||||||
return render_template('verification_review.html',
|
return render_template('verification_review.html',
|
||||||
verifications=verifications,
|
verifications=verifications,
|
||||||
pending_count=pending_count,
|
pending_count=pending_count,
|
||||||
@@ -5388,7 +5448,14 @@ def verification_review():
|
|||||||
rejected_count=rejected_count,
|
rejected_count=rejected_count,
|
||||||
status_filter=status_filter,
|
status_filter=status_filter,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to)
|
date_to=date_to,
|
||||||
|
project_filter=project_filter,
|
||||||
|
location_filter=location_filter,
|
||||||
|
employee_filter=employee_filter,
|
||||||
|
projects=projects,
|
||||||
|
locations=location_list,
|
||||||
|
employee_names=employee_names,
|
||||||
|
project_names=project_names)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger_handler.logger.error(f"Error in verification review: {e}")
|
logger_handler.logger.error(f"Error in verification review: {e}")
|
||||||
@@ -5552,6 +5619,22 @@ def verification_review_detail(record_id):
|
|||||||
# Get the QR code information for additional context
|
# Get the QR code information for additional context
|
||||||
qr_code = QRCode.query.get(record.qr_code_id) if record.qr_code_id else None
|
qr_code = QRCode.query.get(record.qr_code_id) if record.qr_code_id else None
|
||||||
|
|
||||||
|
# Get employee name from Employee table
|
||||||
|
employee_name = None
|
||||||
|
try:
|
||||||
|
if record.employee_id:
|
||||||
|
employee = Employee.query.filter_by(id=int(record.employee_id)).first()
|
||||||
|
if employee:
|
||||||
|
employee_name = f"{employee.lastName}, {employee.firstName}"
|
||||||
|
else:
|
||||||
|
employee_name = f"Unknown (ID: {record.employee_id})"
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger_handler.logger.warning(f"Could not lookup employee name for ID {record.employee_id}: {e}")
|
||||||
|
employee_name = f"Unknown (ID: {record.employee_id})"
|
||||||
|
|
||||||
|
# Get event type from QR code (Check In/Check Out)
|
||||||
|
location_event = qr_code.location_event if qr_code and qr_code.location_event else 'N/A'
|
||||||
|
|
||||||
# Log the access for audit trail
|
# Log the access for audit trail
|
||||||
logger_handler.logger.info(
|
logger_handler.logger.info(
|
||||||
f"User {session.get('username')} ({session.get('role')}) "
|
f"User {session.get('username')} ({session.get('role')}) "
|
||||||
@@ -5573,7 +5656,9 @@ def verification_review_detail(record_id):
|
|||||||
record=record,
|
record=record,
|
||||||
qr_code=qr_code,
|
qr_code=qr_code,
|
||||||
check_in_date=check_in_date,
|
check_in_date=check_in_date,
|
||||||
check_in_time=check_in_time)
|
check_in_time=check_in_time,
|
||||||
|
employee_name=employee_name,
|
||||||
|
location_event=location_event)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger_handler.logger.error(f"Error loading verification review detail: {e}")
|
logger_handler.logger.error(f"Error loading verification review detail: {e}")
|
||||||
@@ -8512,6 +8597,9 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Import parse function at the beginning for work type detection
|
||||||
|
from working_hours_calculator import parse_employee_id_for_work_type
|
||||||
|
|
||||||
# Convert TimeAttendance records to format expected by calculator
|
# Convert TimeAttendance records to format expected by calculator
|
||||||
converted_records = []
|
converted_records = []
|
||||||
for record in records:
|
for record in records:
|
||||||
@@ -8525,12 +8613,24 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
|||||||
if 'out' in action_lower or 'checkout' in action_lower:
|
if 'out' in action_lower or 'checkout' in action_lower:
|
||||||
record_type = 'check_out'
|
record_type = 'check_out'
|
||||||
|
|
||||||
|
# Extract work type (PT, SP, PW) from employee_id for location display in Excel
|
||||||
|
_, work_type = parse_employee_id_for_work_type(str(record.employee_id))
|
||||||
|
|
||||||
|
# Create display location name with work type suffix if applicable
|
||||||
|
base_location_name = record.location_name
|
||||||
|
if work_type and work_type in ('PT', 'SP', 'PW'):
|
||||||
|
display_location_name = f"{base_location_name} ({work_type})"
|
||||||
|
else:
|
||||||
|
display_location_name = base_location_name
|
||||||
|
|
||||||
converted_record = type('Record', (), {
|
converted_record = type('Record', (), {
|
||||||
'id': record.id,
|
'id': record.id,
|
||||||
'employee_id': str(record.employee_id),
|
'employee_id': str(record.employee_id),
|
||||||
'check_in_date': record.attendance_date,
|
'check_in_date': record.attendance_date,
|
||||||
'check_in_time': record.attendance_time,
|
'check_in_time': record.attendance_time,
|
||||||
'location_name': record.location_name,
|
'location_name': display_location_name, # Use display name with work type for Excel export
|
||||||
|
'original_location_name': base_location_name, # Keep original for internal grouping
|
||||||
|
'work_type': work_type, # Store work type for reference
|
||||||
'latitude': None,
|
'latitude': None,
|
||||||
'longitude': None,
|
'longitude': None,
|
||||||
'distance': distance_value,
|
'distance': distance_value,
|
||||||
@@ -8539,13 +8639,29 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
|||||||
'event_description': record.event_description or '',
|
'event_description': record.event_description or '',
|
||||||
'recorded_address': record.recorded_address or '',
|
'recorded_address': record.recorded_address or '',
|
||||||
'qr_code': type('QRCode', (), {
|
'qr_code': type('QRCode', (), {
|
||||||
'location': record.location_name,
|
'location': base_location_name, # Keep original for QR code matching
|
||||||
'location_address': record.recorded_address or '',
|
'location_address': record.recorded_address or '',
|
||||||
'project': None
|
'project': None
|
||||||
})()
|
})()
|
||||||
})()
|
})()
|
||||||
converted_records.append(converted_record)
|
converted_records.append(converted_record)
|
||||||
|
|
||||||
|
# Log count of records with work types for audit trail
|
||||||
|
work_type_counts = {'PT': 0, 'SP': 0, 'PW': 0, 'Regular': 0}
|
||||||
|
for r in converted_records:
|
||||||
|
wt = getattr(r, 'work_type', None)
|
||||||
|
if wt in work_type_counts:
|
||||||
|
work_type_counts[wt] += 1
|
||||||
|
else:
|
||||||
|
work_type_counts['Regular'] += 1
|
||||||
|
|
||||||
|
if any(work_type_counts[wt] > 0 for wt in ['PT', 'SP', 'PW']):
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"Excel Export: Processing records with work types - "
|
||||||
|
f"Regular: {work_type_counts['Regular']}, PT: {work_type_counts['PT']}, "
|
||||||
|
f"SP: {work_type_counts['SP']}, PW: {work_type_counts['PW']}"
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate working hours using SingleCheckInCalculator
|
# Calculate working hours using SingleCheckInCalculator
|
||||||
calculator = WorkingHoursCalculator()
|
calculator = WorkingHoursCalculator()
|
||||||
hours_data = calculator.calculate_all_employees_hours(
|
hours_data = calculator.calculate_all_employees_hours(
|
||||||
@@ -9113,7 +9229,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
|||||||
|
|
||||||
# Write PT row if hours > 0
|
# Write PT row if hours > 0
|
||||||
if pt_hours > 0:
|
if pt_hours > 0:
|
||||||
ws.cell(row=current_row, column=7, value='Part-Time (PT): ').font = Font(name='Arial', size=10, bold=True, italic=True)
|
ws.cell(row=current_row, column=7, value='Project Team (PT): ').font = Font(name='Arial', size=10, bold=True, italic=True)
|
||||||
ws.cell(row=current_row, column=9, value=round(pt_hours, 2)).font = Font(name='Arial', size=10, bold=True, italic=True)
|
ws.cell(row=current_row, column=9, value=round(pt_hours, 2)).font = Font(name='Arial', size=10, bold=True, italic=True)
|
||||||
# Log PT hours export
|
# Log PT hours export
|
||||||
logger_handler.logger.info(f"Export: Employee {employee_id} PT hours: {pt_hours:.2f}")
|
logger_handler.logger.info(f"Export: Employee {employee_id} PT hours: {pt_hours:.2f}")
|
||||||
@@ -9177,6 +9293,611 @@ def excel_export_time_attendance():
|
|||||||
# Redirect to main export with Excel format
|
# Redirect to main export with Excel format
|
||||||
return redirect(url_for('export_time_attendance', format='excel', **request.args))
|
return redirect(url_for('export_time_attendance', format='excel', **request.args))
|
||||||
|
|
||||||
|
@app.route('/time-attendance/export-by-building')
|
||||||
|
@login_required
|
||||||
|
@log_user_activity('time_attendance_export_by_building')
|
||||||
|
def export_time_attendance_by_building():
|
||||||
|
"""Export time attendance records grouped by building/location to Excel"""
|
||||||
|
try:
|
||||||
|
# Get filter parameters (same as records page)
|
||||||
|
employee_filter = request.args.get('employee_id')
|
||||||
|
location_filter = request.args.get('location_name')
|
||||||
|
start_date = request.args.get('start_date')
|
||||||
|
end_date = request.args.get('end_date')
|
||||||
|
import_batch = request.args.get('import_batch')
|
||||||
|
project_filter = request.args.get('project_id')
|
||||||
|
|
||||||
|
# Build query with same filters as the view
|
||||||
|
from models.time_attendance import TimeAttendance
|
||||||
|
query = TimeAttendance.query
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if employee_filter:
|
||||||
|
query = query.filter(TimeAttendance.employee_id == employee_filter)
|
||||||
|
|
||||||
|
if location_filter:
|
||||||
|
query = query.filter(TimeAttendance.location_name == location_filter)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||||
|
query = query.filter(TimeAttendance.attendance_date >= start_date_obj)
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid start date format.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_records'))
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||||
|
query = query.filter(TimeAttendance.attendance_date <= end_date_obj)
|
||||||
|
except ValueError:
|
||||||
|
flash('Invalid end date format.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_records'))
|
||||||
|
|
||||||
|
if import_batch:
|
||||||
|
query = query.filter(TimeAttendance.import_batch_id == import_batch)
|
||||||
|
|
||||||
|
if project_filter:
|
||||||
|
query = query.filter(TimeAttendance.project_id == project_filter)
|
||||||
|
|
||||||
|
# Order by location, date, and time
|
||||||
|
records = query.order_by(
|
||||||
|
TimeAttendance.location_name,
|
||||||
|
TimeAttendance.attendance_date.desc(),
|
||||||
|
TimeAttendance.attendance_time.desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
flash('No records found to export.', 'warning')
|
||||||
|
return redirect(url_for('time_attendance_records'))
|
||||||
|
|
||||||
|
# Get project name if project filter exists
|
||||||
|
project_name_for_filename = ''
|
||||||
|
if project_filter:
|
||||||
|
try:
|
||||||
|
from models.project import Project
|
||||||
|
project = Project.query.get(int(project_filter))
|
||||||
|
if project:
|
||||||
|
# Replace spaces and special characters with underscores
|
||||||
|
project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_')
|
||||||
|
project_name_for_filename = f"{project_name_safe}_"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error getting project name for filename: {e}")
|
||||||
|
|
||||||
|
# Log export
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"User {session['username']} exported {len(records)} time attendance records "
|
||||||
|
f"by building in Excel format"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format dates for filename (MMDDYYYY format)
|
||||||
|
date_from_formatted = ''
|
||||||
|
date_to_formatted = ''
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
date_obj = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
date_from_formatted = date_obj.strftime('%m%d%Y')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
date_obj = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
date_to_formatted = date_obj.strftime('%m%d%Y')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build filename with date range
|
||||||
|
date_range_str = ''
|
||||||
|
if date_from_formatted and date_to_formatted:
|
||||||
|
date_range_str = f"{date_from_formatted}_{date_to_formatted}"
|
||||||
|
elif date_from_formatted:
|
||||||
|
date_range_str = f"from_{date_from_formatted}"
|
||||||
|
elif date_to_formatted:
|
||||||
|
date_range_str = f"to_{date_to_formatted}"
|
||||||
|
|
||||||
|
return export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date, end_date)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_handler.logger.error(f"Error exporting time attendance records by building: {e}")
|
||||||
|
flash('Error generating export file. Please try again.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_records'))
|
||||||
|
|
||||||
|
def export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date_filter=None, end_date_filter=None):
|
||||||
|
"""Generate Excel export grouped by building/location with template format"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Create workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sheet0"
|
||||||
|
|
||||||
|
# Get date range for calculations
|
||||||
|
if start_date_filter and end_date_filter:
|
||||||
|
if isinstance(start_date_filter, str):
|
||||||
|
start_date = datetime.strptime(start_date_filter, '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
start_date = start_date_filter
|
||||||
|
|
||||||
|
if isinstance(end_date_filter, str):
|
||||||
|
end_date = datetime.strptime(end_date_filter, '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
end_date = end_date_filter
|
||||||
|
elif records:
|
||||||
|
start_date = min(r.attendance_date for r in records)
|
||||||
|
end_date = max(r.attendance_date for r in records)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Import parse function for work type detection
|
||||||
|
from working_hours_calculator import parse_employee_id_for_work_type
|
||||||
|
|
||||||
|
# Convert TimeAttendance records to format expected by calculator
|
||||||
|
converted_records = []
|
||||||
|
for record in records:
|
||||||
|
distance_value = getattr(record, 'distance', None)
|
||||||
|
|
||||||
|
record_type = 'check_in'
|
||||||
|
if hasattr(record, 'action_description') and record.action_description:
|
||||||
|
action_lower = record.action_description.lower()
|
||||||
|
if 'out' in action_lower or 'checkout' in action_lower:
|
||||||
|
record_type = 'check_out'
|
||||||
|
|
||||||
|
_, work_type = parse_employee_id_for_work_type(str(record.employee_id))
|
||||||
|
|
||||||
|
base_location_name = record.location_name
|
||||||
|
if work_type and work_type in ('PT', 'SP', 'PW'):
|
||||||
|
display_location_name = f"{base_location_name} ({work_type})"
|
||||||
|
else:
|
||||||
|
display_location_name = base_location_name
|
||||||
|
|
||||||
|
converted_record = type('Record', (), {
|
||||||
|
'id': record.id,
|
||||||
|
'employee_id': str(record.employee_id),
|
||||||
|
'employee_name': record.employee_name,
|
||||||
|
'check_in_date': record.attendance_date,
|
||||||
|
'check_in_time': record.attendance_time,
|
||||||
|
'location_name': display_location_name,
|
||||||
|
'original_location_name': base_location_name,
|
||||||
|
'work_type': work_type,
|
||||||
|
'latitude': None,
|
||||||
|
'longitude': None,
|
||||||
|
'distance': distance_value,
|
||||||
|
'record_type': record_type,
|
||||||
|
'action_description': record.action_description,
|
||||||
|
'event_description': record.event_description or '',
|
||||||
|
'recorded_address': record.recorded_address or '',
|
||||||
|
'qr_code': type('QRCode', (), {
|
||||||
|
'location': base_location_name,
|
||||||
|
'location_address': record.recorded_address or '',
|
||||||
|
'project': None
|
||||||
|
})()
|
||||||
|
})()
|
||||||
|
converted_records.append(converted_record)
|
||||||
|
|
||||||
|
# Group records by location (building)
|
||||||
|
location_groups = {}
|
||||||
|
for record in converted_records:
|
||||||
|
loc_name = record.original_location_name or 'Unknown Location'
|
||||||
|
if loc_name not in location_groups:
|
||||||
|
location_groups[loc_name] = []
|
||||||
|
location_groups[loc_name].append(record)
|
||||||
|
|
||||||
|
# Sort locations alphabetically
|
||||||
|
sorted_locations = sorted(location_groups.keys())
|
||||||
|
|
||||||
|
# Log grouping info
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"Export by Building: Grouped {len(converted_records)} records into {len(sorted_locations)} locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate working hours using WorkingHoursCalculator for SP/PT/PW hours
|
||||||
|
calculator = WorkingHoursCalculator()
|
||||||
|
hours_data = calculator.calculate_all_employees_hours(
|
||||||
|
datetime.combine(start_date, datetime.min.time()),
|
||||||
|
datetime.combine(end_date, datetime.max.time()),
|
||||||
|
converted_records
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get employee names map
|
||||||
|
employee_names = {}
|
||||||
|
for record in records:
|
||||||
|
base_id, _ = parse_employee_id_for_work_type(str(record.employee_id))
|
||||||
|
if base_id not in employee_names:
|
||||||
|
employee_names[base_id] = record.employee_name
|
||||||
|
|
||||||
|
# Setup styles
|
||||||
|
header_font = Font(name='Arial', size=11, bold=True, color='FFFFFF')
|
||||||
|
header_fill = PatternFill(start_color='000000', end_color='000000', fill_type='solid')
|
||||||
|
data_font = Font(name='Arial', size=10)
|
||||||
|
bold_font = Font(name='Arial', size=10, bold=True)
|
||||||
|
italic_bold_font = Font(name='Arial', size=10, bold=True, italic=True)
|
||||||
|
border = Border(
|
||||||
|
left=Side(style='thin'),
|
||||||
|
right=Side(style='thin'),
|
||||||
|
top=Side(style='thin'),
|
||||||
|
bottom=Side(style='thin')
|
||||||
|
)
|
||||||
|
missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid')
|
||||||
|
|
||||||
|
# Write main headers
|
||||||
|
current_row = 1
|
||||||
|
|
||||||
|
# Row 1: Company name
|
||||||
|
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||||
|
title_cell = ws.cell(row=current_row, column=1, value=os.environ.get('COMPANY_NAME', 'Your Company'))
|
||||||
|
title_cell.font = Font(name='Arial', size=14, bold=True)
|
||||||
|
title_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Row 2: Summary title
|
||||||
|
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||||
|
summary_cell = ws.cell(row=current_row, column=1, value='Summary report of Hours worked')
|
||||||
|
summary_cell.font = Font(name='Arial', size=12, bold=True)
|
||||||
|
summary_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Row 3: Project name
|
||||||
|
project_display = project_name_for_filename.replace('_', ' ').strip() if project_name_for_filename else "[Project Name]"
|
||||||
|
project_cell = ws.cell(row=current_row, column=1, value=project_display)
|
||||||
|
project_cell.font = Font(name='Arial', size=11, bold=True)
|
||||||
|
project_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Row 4: Date range
|
||||||
|
date_range_text = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}"
|
||||||
|
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||||
|
date_cell = ws.cell(row=current_row, column=1, value=date_range_text)
|
||||||
|
date_cell.font = Font(name='Arial', size=11)
|
||||||
|
date_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Empty rows before first building
|
||||||
|
current_row += 2
|
||||||
|
|
||||||
|
# Process each building/location
|
||||||
|
for location_index, location_name in enumerate(sorted_locations, 1):
|
||||||
|
location_records = location_groups[location_name]
|
||||||
|
|
||||||
|
# Get zone info from QR code if available
|
||||||
|
zone_info = ''
|
||||||
|
try:
|
||||||
|
qr_code = QRCode.query.filter_by(location=location_name).first()
|
||||||
|
if qr_code:
|
||||||
|
zone_info = getattr(qr_code, 'zone', '') or ''
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Building header row
|
||||||
|
building_header = f"{location_index}) {location_name} - Zone {zone_info}"
|
||||||
|
ws.merge_cells(f'A{current_row}:O{current_row}')
|
||||||
|
building_cell = ws.cell(row=current_row, column=1, value=building_header)
|
||||||
|
building_cell.font = Font(name='Arial', size=11, bold=True)
|
||||||
|
building_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Get unique employees for this location
|
||||||
|
employees_at_location = {}
|
||||||
|
for record in location_records:
|
||||||
|
base_id, _ = parse_employee_id_for_work_type(record.employee_id)
|
||||||
|
if base_id not in employees_at_location:
|
||||||
|
employees_at_location[base_id] = []
|
||||||
|
employees_at_location[base_id].append(record)
|
||||||
|
|
||||||
|
# Sort employees by name
|
||||||
|
sorted_employee_ids = sorted(
|
||||||
|
employees_at_location.keys(),
|
||||||
|
key=lambda emp_id: employee_names.get(emp_id, f'Employee {emp_id}').lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process each employee at this location
|
||||||
|
for employee_id in sorted_employee_ids:
|
||||||
|
emp_records = employees_at_location[employee_id]
|
||||||
|
emp_name = employee_names.get(employee_id, f'Employee {employee_id}')
|
||||||
|
|
||||||
|
# Get SP/PT/PW hours from the calculator's hours_data for this employee
|
||||||
|
emp_hours_data = hours_data.get('employees', {}).get(employee_id, {})
|
||||||
|
grand_totals = emp_hours_data.get('grand_totals', {})
|
||||||
|
sp_hours = grand_totals.get('sp_hours', 0.0)
|
||||||
|
pw_hours = grand_totals.get('pw_hours', 0.0)
|
||||||
|
pt_hours = grand_totals.get('pt_hours', 0.0)
|
||||||
|
|
||||||
|
# Employee header row
|
||||||
|
ws.merge_cells(f'A{current_row}:O{current_row}')
|
||||||
|
emp_header = ws.cell(row=current_row, column=1,
|
||||||
|
value=f'Employee ID {employee_id}: {emp_name}')
|
||||||
|
emp_header.font = Font(name='Arial', size=11, bold=True)
|
||||||
|
emp_header.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Column headers
|
||||||
|
headers = ['Day', 'Date', 'In', 'Out', 'Location', 'Zone', 'Hours/Building',
|
||||||
|
'Daily Total', 'Regular Hours', 'OT Hours', 'Building Address',
|
||||||
|
'Recorded Location', 'Distance (Mile)', 'Possible Violation']
|
||||||
|
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=current_row, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.border = border
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Group employee records by date
|
||||||
|
daily_records = {}
|
||||||
|
for record in emp_records:
|
||||||
|
date_key = record.check_in_date.strftime('%Y-%m-%d')
|
||||||
|
if date_key not in daily_records:
|
||||||
|
daily_records[date_key] = []
|
||||||
|
daily_records[date_key].append(record)
|
||||||
|
|
||||||
|
# Track weekly hours for overtime calculation
|
||||||
|
weekly_total_hours = 0
|
||||||
|
current_week_start = None
|
||||||
|
grand_regular_hours = 0
|
||||||
|
grand_ot_hours = 0
|
||||||
|
|
||||||
|
# Sort dates
|
||||||
|
sorted_dates = sorted(daily_records.keys())
|
||||||
|
|
||||||
|
for date_str in sorted_dates:
|
||||||
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
|
||||||
|
day_records = sorted(daily_records[date_str], key=lambda x: x.check_in_time)
|
||||||
|
|
||||||
|
# Check for week boundary
|
||||||
|
week_start = date_obj - timedelta(days=date_obj.weekday())
|
||||||
|
if current_week_start is not None and week_start != current_week_start:
|
||||||
|
# Write weekly total row
|
||||||
|
week_regular = min(weekly_total_hours, 40.0)
|
||||||
|
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||||
|
|
||||||
|
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||||
|
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
|
||||||
|
|
||||||
|
grand_regular_hours += week_regular
|
||||||
|
grand_ot_hours += week_overtime
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
weekly_total_hours = 0
|
||||||
|
|
||||||
|
current_week_start = week_start
|
||||||
|
|
||||||
|
# Process day records - create IN/OUT pairs
|
||||||
|
record_info = []
|
||||||
|
for record in day_records:
|
||||||
|
action_desc = record.action_description.lower() if record.action_description else ''
|
||||||
|
is_out = 'out' in action_desc or 'checkout' in action_desc
|
||||||
|
record_info.append({
|
||||||
|
'record': record,
|
||||||
|
'is_out': is_out,
|
||||||
|
'used': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create pairs
|
||||||
|
pairs = []
|
||||||
|
i = 0
|
||||||
|
while i < len(record_info):
|
||||||
|
if record_info[i]['used']:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not record_info[i]['is_out']: # IN
|
||||||
|
out_found = False
|
||||||
|
for j in range(i + 1, len(record_info)):
|
||||||
|
if record_info[j]['used']:
|
||||||
|
continue
|
||||||
|
if record_info[j]['is_out']:
|
||||||
|
pairs.append({
|
||||||
|
'check_in': record_info[i]['record'],
|
||||||
|
'check_out': record_info[j]['record'],
|
||||||
|
'is_miss_punch': False
|
||||||
|
})
|
||||||
|
record_info[i]['used'] = True
|
||||||
|
record_info[j]['used'] = True
|
||||||
|
out_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not out_found:
|
||||||
|
pairs.append({
|
||||||
|
'check_in': record_info[i]['record'],
|
||||||
|
'check_out': None,
|
||||||
|
'is_miss_punch': True
|
||||||
|
})
|
||||||
|
record_info[i]['used'] = True
|
||||||
|
else: # Orphaned OUT
|
||||||
|
pairs.append({
|
||||||
|
'check_in': None,
|
||||||
|
'check_out': record_info[i]['record'],
|
||||||
|
'is_miss_punch': True
|
||||||
|
})
|
||||||
|
record_info[i]['used'] = True
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Calculate daily hours
|
||||||
|
daily_hours = 0
|
||||||
|
for pair in pairs:
|
||||||
|
if pair['check_in'] and pair['check_out'] and not pair['is_miss_punch']:
|
||||||
|
pair_in = datetime.combine(date_obj, pair['check_in'].check_in_time)
|
||||||
|
pair_out = datetime.combine(date_obj, pair['check_out'].check_in_time)
|
||||||
|
daily_hours += (pair_out - pair_in).total_seconds() / 3600.0
|
||||||
|
|
||||||
|
daily_hours = round(daily_hours, 2)
|
||||||
|
weekly_total_hours += daily_hours
|
||||||
|
|
||||||
|
# Write pairs
|
||||||
|
for pair_idx, pair in enumerate(pairs):
|
||||||
|
check_in = pair['check_in']
|
||||||
|
check_out = pair['check_out']
|
||||||
|
is_miss_punch = pair['is_miss_punch']
|
||||||
|
|
||||||
|
# Day/date only on first row
|
||||||
|
day_display = date_obj.strftime('%A').upper() if pair_idx == 0 else ''
|
||||||
|
date_display = date_obj.strftime('%m/%d/%Y') if pair_idx == 0 else ''
|
||||||
|
|
||||||
|
# Calculate hours for this pair
|
||||||
|
if check_in and check_out and not is_miss_punch:
|
||||||
|
pair_hours = round((datetime.combine(date_obj, check_out.check_in_time) -
|
||||||
|
datetime.combine(date_obj, check_in.check_in_time)).total_seconds() / 3600.0, 2)
|
||||||
|
else:
|
||||||
|
pair_hours = 'Missed Punch'
|
||||||
|
|
||||||
|
# Daily total only on last row of day
|
||||||
|
daily_total_display = daily_hours if pair_idx == len(pairs) - 1 else ''
|
||||||
|
|
||||||
|
# Get record for address/distance info
|
||||||
|
ref_record = check_in or check_out
|
||||||
|
|
||||||
|
# Build row data
|
||||||
|
row_data = [
|
||||||
|
day_display,
|
||||||
|
date_display,
|
||||||
|
check_in.check_in_time.strftime('%I:%M:%S %p') if check_in else '',
|
||||||
|
check_out.check_in_time.strftime('%I:%M:%S %p') if check_out else '',
|
||||||
|
ref_record.location_name if ref_record else '',
|
||||||
|
zone_info,
|
||||||
|
pair_hours,
|
||||||
|
daily_total_display if daily_total_display else '',
|
||||||
|
'', # Regular Hours
|
||||||
|
'', # OT Hours
|
||||||
|
'', # Building Address (will be HYPERLINK)
|
||||||
|
'', # Recorded Location (will be HYPERLINK)
|
||||||
|
getattr(ref_record, 'distance', None) or '' if ref_record else '',
|
||||||
|
calculate_possible_violation(getattr(ref_record, 'distance', None)) if ref_record else ''
|
||||||
|
]
|
||||||
|
|
||||||
|
for col, value in enumerate(row_data, 1):
|
||||||
|
cell = ws.cell(row=current_row, column=col, value=value)
|
||||||
|
cell.font = data_font
|
||||||
|
cell.border = border
|
||||||
|
if col == 7 and value == 'Missed Punch':
|
||||||
|
cell.fill = missed_punch_fill
|
||||||
|
|
||||||
|
# Add HYPERLINK formulas for addresses
|
||||||
|
if ref_record:
|
||||||
|
building_address = ref_record.event_description or ''
|
||||||
|
if building_address:
|
||||||
|
encoded_addr = building_address.replace(' ', '+').replace(',', '%2C')
|
||||||
|
hyperlink_formula = f'=HYPERLINK("https://www.google.com/maps/place/{encoded_addr}","{building_address}")'
|
||||||
|
ws.cell(row=current_row, column=11, value=hyperlink_formula)
|
||||||
|
|
||||||
|
recorded_addr = ref_record.recorded_address or ''
|
||||||
|
if recorded_addr:
|
||||||
|
encoded_recorded = recorded_addr.replace(' ', '+').replace(',', '%2C')
|
||||||
|
recorded_hyperlink = f'=HYPERLINK("https://www.google.com/maps/place/{encoded_recorded}","{recorded_addr}")'
|
||||||
|
ws.cell(row=current_row, column=12, value=recorded_hyperlink)
|
||||||
|
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Write final weekly total
|
||||||
|
if weekly_total_hours > 0:
|
||||||
|
week_regular = min(weekly_total_hours, 40.0)
|
||||||
|
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||||
|
|
||||||
|
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||||
|
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
|
||||||
|
|
||||||
|
grand_regular_hours += week_regular
|
||||||
|
grand_ot_hours += week_overtime
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Write extra working hours rows (SP/PW/PT) if employee has any
|
||||||
|
# This matches the behavior of the regular Export to Excel
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# Write SP row if hours > 0
|
||||||
|
if sp_hours > 0:
|
||||||
|
ws.cell(row=current_row, column=7, value='Special Project (SP): ').font = italic_bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(sp_hours, 2)).font = italic_bold_font
|
||||||
|
# Log SP hours export
|
||||||
|
logger_handler.logger.info(f"Export by Building: Employee {employee_id} SP hours: {sp_hours:.2f}")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Write PW row if hours > 0
|
||||||
|
if pw_hours > 0:
|
||||||
|
ws.cell(row=current_row, column=7, value='Periodic Work (PW): ').font = italic_bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(pw_hours, 2)).font = italic_bold_font
|
||||||
|
# Log PW hours export
|
||||||
|
logger_handler.logger.info(f"Export by Building: Employee {employee_id} PW hours: {pw_hours:.2f}")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Write PT row if hours > 0
|
||||||
|
if pt_hours > 0:
|
||||||
|
ws.cell(row=current_row, column=7, value='Project Team (PT): ').font = italic_bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(pt_hours, 2)).font = italic_bold_font
|
||||||
|
# Log PT hours export
|
||||||
|
logger_handler.logger.info(f"Export by Building: Employee {employee_id} PT hours: {pt_hours:.2f}")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# End of extra working hours section
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# Write GRAND TOTAL row
|
||||||
|
ws.cell(row=current_row, column=7, value='GRAND TOTAL: ').font = Font(name='Arial', size=10, bold=True)
|
||||||
|
ws.cell(row=current_row, column=9, value=round(grand_regular_hours, 2)).font = Font(name='Arial', size=10, bold=True)
|
||||||
|
ws.cell(row=current_row, column=10, value=round(grand_ot_hours, 2)).font = Font(name='Arial', size=10, bold=True)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Empty row after each employee
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Empty row after each building
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Auto-size columns
|
||||||
|
for col_idx in range(1, 15):
|
||||||
|
column_letter = get_column_letter(col_idx)
|
||||||
|
|
||||||
|
if col_idx == 1:
|
||||||
|
ws.column_dimensions[column_letter].width = 18
|
||||||
|
continue
|
||||||
|
|
||||||
|
max_length = 0
|
||||||
|
for row in ws.iter_rows(min_col=col_idx, max_col=col_idx):
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell, openpyxl.cell.cell.MergedCell):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if cell.value and len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
# Save to BytesIO
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Filename
|
||||||
|
if date_range_str:
|
||||||
|
filename = f'{project_name_for_filename}time_attendance_by_building_{date_range_str}.xlsx'
|
||||||
|
else:
|
||||||
|
filename = f'{project_name_for_filename}time_attendance_by_building.xlsx'
|
||||||
|
|
||||||
|
# Log successful export
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"Export by Building completed: {filename} with {len(sorted_locations)} buildings"
|
||||||
|
)
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
output,
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/time-attendance/records')
|
@app.route('/time-attendance/records')
|
||||||
@login_required
|
@login_required
|
||||||
@log_user_activity('time_attendance_records_view')
|
@log_user_activity('time_attendance_records_view')
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
<i class="fas fa-file-excel"></i>
|
<i class="fas fa-file-excel"></i>
|
||||||
Export to Excel
|
Export to Excel
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="exportByBuilding()">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
Export by Building
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +280,7 @@
|
|||||||
<span class="pagination-btn active">{{ page_num }}</span>
|
<span class="pagination-btn active">{{ page_num }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="pagination-ellipsis">...</span>
|
<span class="pagination-ellipsis">...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@@ -297,16 +301,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="no-records">
|
||||||
<div class="empty-icon">
|
<i class="fas fa-folder-open"></i>
|
||||||
<i class="fas fa-inbox"></i>
|
<p>No attendance records found matching your criteria.</p>
|
||||||
</div>
|
|
||||||
<h3>No Records Found</h3>
|
|
||||||
<p>No time attendance records match your current filters.</p>
|
|
||||||
<a href="{{ url_for('time_attendance_records') }}" class="btn btn-primary">
|
|
||||||
<i class="fas fa-refresh"></i>
|
|
||||||
Clear Filters
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +366,26 @@ function exportRecords(format) {
|
|||||||
window.location.href = `/time-attendance/export?${params.toString()}`;
|
window.location.href = `/time-attendance/export?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportByBuilding() {
|
||||||
|
// Get current filter values
|
||||||
|
const employeeId = document.querySelector('select[name="employee_id"]')?.value || '';
|
||||||
|
const locationName = document.querySelector('select[name="location_name"]')?.value || '';
|
||||||
|
const projectId = document.querySelector('select[name="project_id"]')?.value || '';
|
||||||
|
const startDate = document.querySelector('input[name="start_date"]')?.value || '';
|
||||||
|
const endDate = document.querySelector('input[name="end_date"]')?.value || '';
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (employeeId) params.append('employee_id', employeeId);
|
||||||
|
if (locationName) params.append('location_name', locationName);
|
||||||
|
if (projectId) params.append('project_id', projectId);
|
||||||
|
if (startDate) params.append('start_date', startDate);
|
||||||
|
if (endDate) params.append('end_date', endDate);
|
||||||
|
|
||||||
|
// Redirect to export by building endpoint
|
||||||
|
window.location.href = `/time-attendance/export-by-building?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const menu = document.getElementById('exportMenu');
|
const menu = document.getElementById('exportMenu');
|
||||||
@@ -489,6 +506,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-dropdown-menu {
|
.export-dropdown-menu {
|
||||||
@@ -590,6 +610,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
right: auto;
|
right: auto;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -60,6 +60,36 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="project">Project</label>
|
||||||
|
<select name="project" id="project" class="filter-select">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{% for project in projects %}
|
||||||
|
<option value="{{ project.id }}" {% if project_filter == project.id|string %}selected{% endif %}>
|
||||||
|
{{ project.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="location">Location</label>
|
||||||
|
<select name="location" id="location" class="filter-select">
|
||||||
|
<option value="">All Locations</option>
|
||||||
|
{% for location in locations %}
|
||||||
|
<option value="{{ location }}" {% if location_filter == location %}selected{% endif %}>
|
||||||
|
{{ location }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="employee">Employee ID</label>
|
||||||
|
<input type="text" name="employee" id="employee" value="{{ employee_filter }}"
|
||||||
|
class="filter-input" placeholder="Search by ID...">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="date_from">From Date</label>
|
<label for="date_from">From Date</label>
|
||||||
<input type="date" name="date_from" id="date_from" value="{{ date_from }}" class="filter-input">
|
<input type="date" name="date_from" id="date_from" value="{{ date_from }}" class="filter-input">
|
||||||
@@ -70,10 +100,13 @@
|
|||||||
<input type="date" name="date_to" id="date_to" value="{{ date_to }}" class="filter-input">
|
<input type="date" name="date_to" id="date_to" value="{{ date_to }}" class="filter-input">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group filter-buttons">
|
||||||
<button type="submit" class="btn-filter">
|
<button type="submit" class="btn-filter">
|
||||||
<i class="fas fa-filter"></i> Apply Filters
|
<i class="fas fa-filter"></i> Apply Filters
|
||||||
</button>
|
</button>
|
||||||
|
<a href="{{ url_for('verification_review') }}" class="btn-reset">
|
||||||
|
<i class="fas fa-undo"></i> Reset
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -89,7 +122,10 @@
|
|||||||
<div class="verification-info">
|
<div class="verification-info">
|
||||||
<h3>
|
<h3>
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
Employee {{ record.employee_id }}
|
{{ record.employee_id }}
|
||||||
|
{% if employee_names.get(record.employee_id) %}
|
||||||
|
<span class="employee-name">- {{ employee_names.get(record.employee_id) }}</span>
|
||||||
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="verification-meta">
|
<div class="verification-meta">
|
||||||
<span class="meta-item">
|
<span class="meta-item">
|
||||||
@@ -104,7 +140,21 @@
|
|||||||
<i class="fas fa-map-marker-alt"></i>
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
{{ record.location_name }}
|
{{ record.location_name }}
|
||||||
</span>
|
</span>
|
||||||
|
{% if record.qr_code and record.qr_code.location_event %}
|
||||||
|
<span class="meta-item event-badge {{ 'check-in' if record.qr_code.location_event == 'Check In' else 'check-out' }}">
|
||||||
|
<i class="fas fa-{{ 'sign-in-alt' if record.qr_code.location_event == 'Check In' else 'sign-out-alt' }}"></i>
|
||||||
|
{{ record.qr_code.location_event }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if record.qr_code and record.qr_code.project_id and project_names.get(record.qr_code.project_id) %}
|
||||||
|
<div class="verification-project">
|
||||||
|
<span class="project-badge">
|
||||||
|
<i class="fas fa-folder"></i>
|
||||||
|
{{ project_names.get(record.qr_code.project_id) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="verification-status">
|
<div class="verification-status">
|
||||||
{% if record.verification_status == 'pending' %}
|
{% if record.verification_status == 'pending' %}
|
||||||
@@ -127,7 +177,7 @@
|
|||||||
<div class="photo-section">
|
<div class="photo-section">
|
||||||
<div class="photo-container">
|
<div class="photo-container">
|
||||||
{% if record.verification_photo %}
|
{% if record.verification_photo %}
|
||||||
<img src="{{ record.verification_photo }}" alt="Verification Photo" class="verification-photo" />
|
<img src="{{ record.verification_photo }}" alt="Verification Photo" class="verification-photo" data-record-id="{{ record.id }}" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="no-photo">
|
<div class="no-photo">
|
||||||
<i class="fas fa-image"></i>
|
<i class="fas fa-image"></i>
|
||||||
@@ -135,6 +185,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if record.verification_photo %}
|
||||||
|
<div class="photo-actions">
|
||||||
|
<button type="button" class="btn-photo download" onclick="downloadPhoto({{ record.id }}, '{{ record.employee_id }}', '{{ record.check_in_date.strftime('%Y-%m-%d') }}')" title="Download Photo">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-photo email" onclick="sendByEmail({{ record.id }}, '{{ record.employee_id }}', '{{ employee_names.get(record.employee_id) or 'Unknown' }}', '{{ record.check_in_date.strftime('%b %d, %Y') }}', '{{ record.check_in_time.strftime('%I:%M %p') }}', '{{ record.location_name }}', '{{ record.qr_code.location_event if record.qr_code and record.qr_code.location_event else 'N/A' }}', '{{ record.verification_status }}', '{{ record.qr_code.location_address if record.qr_code and record.qr_code.location_address else 'N/A' }}', '{{ record.address or 'N/A' }}', '{{ '%.3f'|format(record.location_accuracy) if record.location_accuracy else 'N/A' }}', '{{ record.device_info or 'Unknown' }}')" title="Send by Email">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('verification_review_detail', record_id=record.id) }}" class="btn-photo view" title="View Details">
|
||||||
|
<i class="fas fa-expand"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="details-section">
|
<div class="details-section">
|
||||||
@@ -330,6 +393,71 @@
|
|||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-project {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-badge.check-in {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-badge.check-out {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
.verifications-grid {
|
.verifications-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
@@ -439,6 +567,56 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo.download {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo.download:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo.email {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo.email:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo.view {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-photo.view:hover {
|
||||||
|
background: #059669;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
.details-section {
|
.details-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -546,11 +724,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Add note
|
// Prompt for reason
|
||||||
let note = '';
|
const reasonPrompt = status === 'approved'
|
||||||
if (status === 'rejected') {
|
? 'Optional: Enter a reason for approval:'
|
||||||
note = prompt('Optional: Add a note explaining why this was rejected:');
|
: 'Please enter a reason for rejection:';
|
||||||
if (note === null) return; // User cancelled
|
|
||||||
|
let note = prompt(reasonPrompt);
|
||||||
|
|
||||||
|
// For rejection, reason is required
|
||||||
|
if (status === 'rejected' && (!note || note.trim() === '')) {
|
||||||
|
alert('A reason is required when rejecting a verification.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User cancelled the prompt
|
||||||
|
if (note === null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable buttons
|
// Disable buttons
|
||||||
@@ -593,5 +782,90 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Download verification photo
|
||||||
|
function downloadPhoto(recordId, employeeId, checkInDate) {
|
||||||
|
const card = document.querySelector(`[data-record-id="${recordId}"]`);
|
||||||
|
const photoElement = card.querySelector('.verification-photo');
|
||||||
|
|
||||||
|
if (!photoElement) {
|
||||||
|
alert('No photo available to download.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoSrc = photoElement.src;
|
||||||
|
const fileName = `verification_photo_${employeeId}_${checkInDate}.jpg`;
|
||||||
|
|
||||||
|
// Handle base64 data URL
|
||||||
|
if (photoSrc.startsWith('data:')) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = photoSrc;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
console.log('[LOG] Photo downloaded:', fileName);
|
||||||
|
} else {
|
||||||
|
// Handle regular URL - fetch and download
|
||||||
|
fetch(photoSrc)
|
||||||
|
.then(response => response.blob())
|
||||||
|
.then(blob => {
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
console.log('[LOG] Photo downloaded:', fileName);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[ERROR] Failed to download photo:', error);
|
||||||
|
alert('Failed to download photo. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send verification details by email
|
||||||
|
function sendByEmail(recordId, employeeId, employeeName, checkInDate, checkInTime, locationName, locationEvent, verificationStatus, qrAddress, checkInAddress, distance, deviceInfo) {
|
||||||
|
// Build the verification record URL
|
||||||
|
const recordUrl = window.location.origin + `/verification-review/${recordId}`;
|
||||||
|
|
||||||
|
// Build email subject
|
||||||
|
const subject = encodeURIComponent(`Verification Review - Employee ${employeeId} (${employeeName}) - ${checkInDate}`);
|
||||||
|
|
||||||
|
// Build email body with verification details
|
||||||
|
let body = `VERIFICATION PHOTO REVIEW DETAILS\n`;
|
||||||
|
body += `================================\n\n`;
|
||||||
|
body += `VIEW VERIFICATION RECORD ONLINE:\n`;
|
||||||
|
body += `${recordUrl}\n\n`;
|
||||||
|
body += `EMPLOYEE INFORMATION\n`;
|
||||||
|
body += `--------------------\n`;
|
||||||
|
body += `Employee ID: ${employeeId}\n`;
|
||||||
|
body += `Employee Name: ${employeeName}\n`;
|
||||||
|
body += `Check-in Date: ${checkInDate}\n`;
|
||||||
|
body += `Check-in Time: ${checkInTime}\n`;
|
||||||
|
body += `Verification Status: ${verificationStatus.toUpperCase()}\n\n`;
|
||||||
|
body += `LOCATION INFORMATION\n`;
|
||||||
|
body += `--------------------\n`;
|
||||||
|
body += `Location Name: ${locationName}\n`;
|
||||||
|
body += `Event Type: ${locationEvent}\n`;
|
||||||
|
body += `Distance from QR: ${distance} miles\n`;
|
||||||
|
body += `QR Code Address: ${qrAddress}\n`;
|
||||||
|
body += `Check-in Address: ${checkInAddress}\n`;
|
||||||
|
body += `Device: ${deviceInfo}\n\n`;
|
||||||
|
body += `--------------------\n`;
|
||||||
|
body += `Record ID: ${recordId}\n\n`;
|
||||||
|
body += `Note: Click the link above to view the verification photo and full details.\n`;
|
||||||
|
|
||||||
|
const encodedBody = encodeURIComponent(body);
|
||||||
|
|
||||||
|
// Open default email client
|
||||||
|
const mailtoLink = `mailto:?subject=${subject}&body=${encodedBody}`;
|
||||||
|
window.location.href = mailtoLink;
|
||||||
|
|
||||||
|
console.log('[LOG] Email client opened for verification record:', recordId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,53 +1,50 @@
|
|||||||
{% extends "base_authenticated.html" %}
|
{% extends "base_authenticated.html" %} {% block title %}Verification Review -
|
||||||
|
QR Code Management{% endblock %} {% block extra_head %}
|
||||||
{% block title %}Verification Review - QR Code Management{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<style>
|
<style>
|
||||||
.verification-review-page {
|
.verification-review-page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 30px 20px;
|
padding: 30px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-header {
|
.review-header {
|
||||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-header h1 {
|
.review-header h1 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-header p {
|
.review-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-content {
|
.review-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-section {
|
.review-section {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-section h2 {
|
.review-section h2 {
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
@@ -56,107 +53,107 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
border-bottom: 2px solid #e5e7eb;
|
border-bottom: 2px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row:last-child {
|
.info-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.distance-badge {
|
.distance-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.distance-high {
|
.distance-high {
|
||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.distance-medium {
|
.distance-medium {
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.distance-low {
|
.distance-low {
|
||||||
background: #d1fae5;
|
background: #d1fae5;
|
||||||
color: #065f46;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pending {
|
.status-pending {
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-approved {
|
.status-approved {
|
||||||
background: #d1fae5;
|
background: #d1fae5;
|
||||||
color: #065f46;
|
color: #065f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-rejected {
|
.status-rejected {
|
||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-section {
|
.photo-section {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verification-photo {
|
.verification-photo {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
display: block;
|
display: block;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-photo {
|
.no-photo {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-photo i {
|
.no-photo i {
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -164,9 +161,9 @@
|
|||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons .btn {
|
.action-buttons .btn {
|
||||||
padding: 14px 40px;
|
padding: 14px 40px;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -178,263 +175,445 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-approve {
|
.btn-approve {
|
||||||
background: #10b981;
|
background: #10b981;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-approve:hover {
|
.btn-approve:hover {
|
||||||
background: #059669;
|
background: #059669;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
|
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-reject {
|
.btn-reject {
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-reject:hover {
|
.btn-reject:hover {
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.3);
|
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-back {
|
.btn-back {
|
||||||
background: #6b7280;
|
background: #6b7280;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-back:hover {
|
.btn-back:hover {
|
||||||
background: #4b5563;
|
background: #4b5563;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(107, 114, 128, 0.3);
|
box-shadow: 0 6px 16px rgba(107, 114, 128, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.btn-download {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-email {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-email:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-actions .btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-warning {
|
.alert-warning {
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
border-left: 4px solid #f59e0b;
|
border-left: 4px solid #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-info {
|
.alert-info {
|
||||||
background: #dbeafe;
|
background: #dbeafe;
|
||||||
color: #1e40af;
|
color: #1e40af;
|
||||||
border-left: 4px solid #3b82f6;
|
border-left: 4px solid #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.review-content {
|
.review-content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons .btn {
|
.action-buttons .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-header h1 {
|
.review-header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %} {% block content %}
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="verification-review-page">
|
<div class="verification-review-page">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="review-header">
|
<div class="review-header">
|
||||||
<h1>
|
<h1>
|
||||||
<i class="fas fa-camera-retro"></i>
|
<i class="fas fa-camera-retro"></i>
|
||||||
Verification Photo Review
|
Verification Photo Review
|
||||||
</h1>
|
</h1>
|
||||||
<p>Review and approve/reject employee verification photo for off-site check-in</p>
|
<p>
|
||||||
|
Review and approve/reject employee verification photo for off-site
|
||||||
|
check-in
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert for already reviewed records -->
|
||||||
|
{% if record.verification_status != 'pending' %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span
|
||||||
|
>This verification has already been {{ record.verification_status
|
||||||
|
}}.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Review Content -->
|
||||||
|
<div class="review-content">
|
||||||
|
<!-- Employee Information -->
|
||||||
|
<div class="review-section">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
Employee Information
|
||||||
|
</h2>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Employee ID</span>
|
||||||
|
<span class="info-value">{{ record.employee_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Employee Name</span>
|
||||||
|
<span class="info-value">{{ employee_name or 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Check-in Date</span>
|
||||||
|
<span class="info-value">{{ check_in_date }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Check-in Time</span>
|
||||||
|
<span class="info-value">{{ check_in_time }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Status</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<span class="status-badge status-{{ record.verification_status }}">
|
||||||
|
{{ record.verification_status.upper() }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert for already reviewed records -->
|
<!-- Location Information -->
|
||||||
{% if record.verification_status != 'pending' %}
|
<div class="review-section">
|
||||||
<div class="alert alert-info">
|
<h2>
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
<span>This verification has already been {{ record.verification_status }}.</span>
|
Location Information
|
||||||
</div>
|
</h2>
|
||||||
{% endif %}
|
<div class="info-row">
|
||||||
|
<span class="info-label">Location Name</span>
|
||||||
<!-- Review Content -->
|
<span class="info-value">{{ record.location_name or 'Unknown' }}</span>
|
||||||
<div class="review-content">
|
</div>
|
||||||
<!-- Employee Information -->
|
<div class="info-row">
|
||||||
<div class="review-section">
|
<span class="info-label">Event</span>
|
||||||
<h2>
|
<span class="info-value">{{ location_event or 'N/A' }}</span>
|
||||||
<i class="fas fa-user"></i>
|
</div>
|
||||||
Employee Information
|
<div class="info-row">
|
||||||
</h2>
|
<span class="info-label">Distance from QR</span>
|
||||||
<div class="info-row">
|
<span class="info-value">
|
||||||
<span class="info-label">Employee ID</span>
|
{% if record.location_accuracy %} {% if record.location_accuracy > 0.5
|
||||||
<span class="info-value">{{ record.employee_id }}</span>
|
%}
|
||||||
</div>
|
<span class="distance-badge distance-high"
|
||||||
<div class="info-row">
|
>{{ "%.3f"|format(record.location_accuracy) }} miles</span
|
||||||
<span class="info-label">Employee Name</span>
|
>
|
||||||
<span class="info-value">{{ record.employee_name or 'Unknown' }}</span>
|
{% elif record.location_accuracy > 0.2 %}
|
||||||
</div>
|
<span class="distance-badge distance-medium"
|
||||||
<div class="info-row">
|
>{{ "%.3f"|format(record.location_accuracy) }} miles</span
|
||||||
<span class="info-label">Check-in Date</span>
|
>
|
||||||
<span class="info-value">{{ check_in_date }}</span>
|
{% else %}
|
||||||
</div>
|
<span class="distance-badge distance-low"
|
||||||
<div class="info-row">
|
>{{ "%.3f"|format(record.location_accuracy) }} miles</span
|
||||||
<span class="info-label">Check-in Time</span>
|
>
|
||||||
<span class="info-value">{{ check_in_time }}</span>
|
{% endif %} {% else %} N/A {% endif %}
|
||||||
</div>
|
</span>
|
||||||
<div class="info-row">
|
</div>
|
||||||
<span class="info-label">Status</span>
|
<div class="info-row">
|
||||||
<span class="info-value">
|
<span class="info-label">QR Address</span>
|
||||||
<span class="status-badge status-{{ record.verification_status }}">
|
<span class="info-value"
|
||||||
{{ record.verification_status.upper() }}
|
>{{ qr_code.location_address if qr_code else 'N/A' }}</span
|
||||||
</span>
|
>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="info-row">
|
||||||
</div>
|
<span class="info-label">Check-in Address</span>
|
||||||
|
<span class="info-value">{{ record.address or 'N/A' }}</span>
|
||||||
<!-- Location Information -->
|
</div>
|
||||||
<div class="review-section">
|
<div class="info-row">
|
||||||
<h2>
|
<span class="info-label">Device</span>
|
||||||
<i class="fas fa-map-marker-alt"></i>
|
<span class="info-value">{{ record.device_info or 'Unknown' }}</span>
|
||||||
Location Information
|
</div>
|
||||||
</h2>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Location Name</span>
|
|
||||||
<span class="info-value">{{ record.location_name or 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Event</span>
|
|
||||||
<span class="info-value">{{ record.location_event or 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Distance from QR</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{% if record.location_accuracy %}
|
|
||||||
{% if record.location_accuracy > 0.5 %}
|
|
||||||
<span class="distance-badge distance-high">{{ "%.3f"|format(record.location_accuracy) }} miles</span>
|
|
||||||
{% elif record.location_accuracy > 0.2 %}
|
|
||||||
<span class="distance-badge distance-medium">{{ "%.3f"|format(record.location_accuracy) }} miles</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="distance-badge distance-low">{{ "%.3f"|format(record.location_accuracy) }} miles</span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
N/A
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">QR Address</span>
|
|
||||||
<span class="info-value">{{ qr_code.address if qr_code else 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Check-in Address</span>
|
|
||||||
<span class="info-value">{{ record.address or 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Device</span>
|
|
||||||
<span class="info-value">{{ record.device_info or 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verification Photo -->
|
|
||||||
<div class="review-section photo-section">
|
|
||||||
<h2>
|
|
||||||
<i class="fas fa-camera"></i>
|
|
||||||
Verification Photo
|
|
||||||
</h2>
|
|
||||||
{% if record.verification_photo %}
|
|
||||||
<img src="{{ record.verification_photo }}"
|
|
||||||
alt="Verification Photo for {{ record.employee_id }}"
|
|
||||||
class="verification-photo">
|
|
||||||
{% else %}
|
|
||||||
<div class="no-photo">
|
|
||||||
<i class="fas fa-image"></i>
|
|
||||||
<h3>No Photo Available</h3>
|
|
||||||
<p>This record does not have a verification photo.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Verification Photo -->
|
||||||
{% if record.verification_status == 'pending' %}
|
<div class="review-section photo-section">
|
||||||
<div class="action-buttons">
|
<h2>
|
||||||
<button onclick="updateStatus('approved')" class="btn btn-approve">
|
<i class="fas fa-camera"></i>
|
||||||
<i class="fas fa-check"></i>
|
Verification Photo
|
||||||
Approve Verification
|
</h2>
|
||||||
|
{% if record.verification_photo %}
|
||||||
|
<img
|
||||||
|
src="{{ record.verification_photo }}"
|
||||||
|
alt="Verification Photo for {{ record.employee_id }}"
|
||||||
|
class="verification-photo"
|
||||||
|
id="verificationPhoto"
|
||||||
|
/>
|
||||||
|
<div class="photo-actions">
|
||||||
|
<button onclick="downloadPhoto()" class="btn btn-download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
Download Photo
|
||||||
</button>
|
</button>
|
||||||
<button onclick="updateStatus('rejected')" class="btn btn-reject">
|
<button onclick="sendByEmail()" class="btn btn-email">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-envelope"></i>
|
||||||
Reject Verification
|
Send by Email
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('attendance_report') }}" class="btn btn-back">
|
</div>
|
||||||
<i class="fas fa-arrow-left"></i>
|
{% else %}
|
||||||
Back to Attendance
|
<div class="no-photo">
|
||||||
</a>
|
<i class="fas fa-image"></i>
|
||||||
|
<h3>No Photo Available</h3>
|
||||||
|
<p>This record does not have a verification photo.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
<div class="action-buttons">
|
|
||||||
<a href="{{ url_for('attendance_report') }}" class="btn btn-back">
|
<!-- Action Buttons -->
|
||||||
<i class="fas fa-arrow-left"></i>
|
{% if record.verification_status == 'pending' %}
|
||||||
Back to Attendance
|
<div class="action-buttons">
|
||||||
</a>
|
<button onclick="updateStatus('approved')" class="btn btn-approve">
|
||||||
</div>
|
<i class="fas fa-check"></i>
|
||||||
{% endif %}
|
Approve Verification
|
||||||
|
</button>
|
||||||
|
<button onclick="updateStatus('rejected')" class="btn btn-reject">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
Reject Verification
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('attendance_report') }}" class="btn btn-back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
Back to Attendance
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a href="{{ url_for('attendance_report') }}" class="btn btn-back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
Back to Attendance
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function updateStatus(status) {
|
function updateStatus(status) {
|
||||||
const confirmMessage = status === 'approved'
|
const statusText = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
? 'Are you sure you want to APPROVE this verification?'
|
const confirmMessage = status === 'approved'
|
||||||
: 'Are you sure you want to REJECT this verification?';
|
? 'Are you sure you want to APPROVE this verification?'
|
||||||
|
: 'Are you sure you want to REJECT this verification?';
|
||||||
|
|
||||||
if (!confirm(confirmMessage)) {
|
if (!confirm(confirmMessage)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[LOG] Updating verification status to ${status} for record {{ record.id }}`);
|
// Prompt for reason
|
||||||
|
const reasonPrompt = status === 'approved'
|
||||||
|
? 'Optional: Enter a reason for approval:'
|
||||||
|
: 'Please enter a reason for rejection:';
|
||||||
|
|
||||||
// Send update request
|
let reason = prompt(reasonPrompt);
|
||||||
fetch('/verification-review/{{ record.id }}/update', {
|
|
||||||
method: 'POST',
|
// For rejection, reason is required
|
||||||
headers: {
|
if (status === 'rejected' && (!reason || reason.trim() === '')) {
|
||||||
'Content-Type': 'application/json',
|
alert('A reason is required when rejecting a verification.');
|
||||||
},
|
return;
|
||||||
body: JSON.stringify({
|
}
|
||||||
status: status
|
|
||||||
})
|
// User cancelled the prompt
|
||||||
})
|
if (reason === null) {
|
||||||
.then(response => response.json())
|
return;
|
||||||
.then(data => {
|
}
|
||||||
if (data.success) {
|
|
||||||
console.log(`[LOG] Successfully updated verification status to ${status}`);
|
console.log(`[LOG] Updating verification status to ${status} for record {{ record.id }}`);
|
||||||
alert(`Verification ${status} successfully!`);
|
|
||||||
// Redirect back to attendance report
|
// Send update request
|
||||||
window.location.href = '{{ url_for('attendance_report') }}';
|
fetch('/verification-review/{{ record.id }}/update', {
|
||||||
} else {
|
method: 'POST',
|
||||||
throw new Error(data.message || 'Failed to update verification status');
|
headers: {
|
||||||
}
|
'Content-Type': 'application/json',
|
||||||
})
|
},
|
||||||
.catch(error => {
|
body: JSON.stringify({
|
||||||
console.error('[ERROR] Failed to update verification status:', error);
|
status: status,
|
||||||
alert(`Error: ${error.message}`);
|
note: reason
|
||||||
});
|
})
|
||||||
}
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log(`[LOG] Successfully updated verification status to ${status}`);
|
||||||
|
alert(`Verification ${status} successfully!`);
|
||||||
|
// Redirect back to attendance report
|
||||||
|
window.location.href = '{{ url_for('attendance_report') }}';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Failed to update verification status');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[ERROR] Failed to update verification status:', error);
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download verification photo
|
||||||
|
function downloadPhoto() {
|
||||||
|
const photoElement = document.getElementById('verificationPhoto');
|
||||||
|
if (!photoElement) {
|
||||||
|
alert('No photo available to download.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoSrc = photoElement.src;
|
||||||
|
const employeeId = '{{ record.employee_id }}';
|
||||||
|
const checkInDate = '{{ check_in_date }}'.replace(/\//g, '-');
|
||||||
|
const fileName = `verification_photo_${employeeId}_${checkInDate}.jpg`;
|
||||||
|
|
||||||
|
// Handle base64 data URL
|
||||||
|
if (photoSrc.startsWith('data:')) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = photoSrc;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
console.log('[LOG] Photo downloaded:', fileName);
|
||||||
|
} else {
|
||||||
|
// Handle regular URL - fetch and download
|
||||||
|
fetch(photoSrc)
|
||||||
|
.then(response => response.blob())
|
||||||
|
.then(blob => {
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
console.log('[LOG] Photo downloaded:', fileName);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[ERROR] Failed to download photo:', error);
|
||||||
|
alert('Failed to download photo. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send verification details by email
|
||||||
|
function sendByEmail() {
|
||||||
|
const employeeId = '{{ record.employee_id }}';
|
||||||
|
const employeeName = '{{ employee_name or "Unknown" }}';
|
||||||
|
const checkInDate = '{{ check_in_date }}';
|
||||||
|
const checkInTime = '{{ check_in_time }}';
|
||||||
|
const locationName = '{{ record.location_name or "Unknown" }}';
|
||||||
|
const locationEvent = '{{ location_event or "N/A" }}';
|
||||||
|
const verificationStatus = '{{ record.verification_status }}';
|
||||||
|
const qrAddress = '{{ qr_code.location_address if qr_code else "N/A" }}';
|
||||||
|
const checkInAddress = '{{ record.address or "N/A" }}';
|
||||||
|
const distance = '{% if record.location_accuracy %}{{ "%.3f"|format(record.location_accuracy) }} miles{% else %}N/A{% endif %}';
|
||||||
|
const deviceInfo = '{{ record.device_info or "Unknown" }}';
|
||||||
|
const recordId = '{{ record.id }}';
|
||||||
|
const recordUrl = window.location.origin + '{{ url_for("verification_review_detail", record_id=record.id) }}';
|
||||||
|
|
||||||
|
// Build email subject
|
||||||
|
const subject = encodeURIComponent(`Verification Review - Employee ${employeeId} (${employeeName}) - ${checkInDate}`);
|
||||||
|
|
||||||
|
// Build email body with verification details
|
||||||
|
let body = `VERIFICATION PHOTO REVIEW DETAILS\n`;
|
||||||
|
body += `================================\n\n`;
|
||||||
|
body += `VIEW VERIFICATION RECORD ONLINE:\n`;
|
||||||
|
body += `${recordUrl}\n\n`;
|
||||||
|
body += `EMPLOYEE INFORMATION\n`;
|
||||||
|
body += `--------------------\n`;
|
||||||
|
body += `Employee ID: ${employeeId}\n`;
|
||||||
|
body += `Employee Name: ${employeeName}\n`;
|
||||||
|
body += `Check-in Date: ${checkInDate}\n`;
|
||||||
|
body += `Check-in Time: ${checkInTime}\n`;
|
||||||
|
body += `Verification Status: ${verificationStatus.toUpperCase()}\n\n`;
|
||||||
|
body += `LOCATION INFORMATION\n`;
|
||||||
|
body += `--------------------\n`;
|
||||||
|
body += `Location Name: ${locationName}\n`;
|
||||||
|
body += `Event Type: ${locationEvent}\n`;
|
||||||
|
body += `Distance from QR: ${distance}\n`;
|
||||||
|
body += `QR Code Address: ${qrAddress}\n`;
|
||||||
|
body += `Check-in Address: ${checkInAddress}\n`;
|
||||||
|
body += `Device: ${deviceInfo}\n\n`;
|
||||||
|
body += `--------------------\n`;
|
||||||
|
body += `Record ID: ${recordId}\n\n`;
|
||||||
|
body += `Note: Click the link above to view the verification photo and full details.\n`;
|
||||||
|
|
||||||
|
const encodedBody = encodeURIComponent(body);
|
||||||
|
|
||||||
|
// Open default email client
|
||||||
|
const mailtoLink = `mailto:?subject=${subject}&body=${encodedBody}`;
|
||||||
|
window.location.href = mailtoLink;
|
||||||
|
|
||||||
|
console.log('[LOG] Email client opened for verification record:', recordId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user