Attendance log now record both check in and out photos and allow IT Staffs, Dispatchers and Admins to view for verification

This commit is contained in:
Marc Rejohn Castillano 2026-03-08 18:45:31 +08:00
parent f8502f01b6
commit 0f675d4274
7 changed files with 503 additions and 35 deletions

View File

@ -13,8 +13,10 @@ class AttendanceLog {
this.checkOutLat, this.checkOutLat,
this.checkOutLng, this.checkOutLng,
this.justification, this.justification,
this.checkOutJustification,
this.verificationStatus = 'pending', this.verificationStatus = 'pending',
this.verificationPhotoUrl, this.checkInVerificationPhotoUrl,
this.checkOutVerificationPhotoUrl,
}); });
final String id; final String id;
@ -28,8 +30,10 @@ class AttendanceLog {
final double? checkOutLat; final double? checkOutLat;
final double? checkOutLng; final double? checkOutLng;
final String? justification; final String? justification;
final String? checkOutJustification;
final String verificationStatus; // pending, verified, unverified, skipped final String verificationStatus; // pending, verified, unverified, skipped
final String? verificationPhotoUrl; final String? checkInVerificationPhotoUrl;
final String? checkOutVerificationPhotoUrl;
bool get isCheckedOut => checkOutAt != null; bool get isCheckedOut => checkOutAt != null;
bool get isVerified => verificationStatus == 'verified'; bool get isVerified => verificationStatus == 'verified';
@ -37,6 +41,8 @@ class AttendanceLog {
verificationStatus == 'unverified' || verificationStatus == 'skipped'; verificationStatus == 'unverified' || verificationStatus == 'skipped';
factory AttendanceLog.fromMap(Map<String, dynamic> map) { factory AttendanceLog.fromMap(Map<String, dynamic> map) {
// Support both old single-column and new dual-column schemas.
final legacyUrl = map['verification_photo_url'] as String?;
return AttendanceLog( return AttendanceLog(
id: map['id'] as String, id: map['id'] as String,
userId: map['user_id'] as String, userId: map['user_id'] as String,
@ -51,8 +57,12 @@ class AttendanceLog {
checkOutLat: (map['check_out_lat'] as num?)?.toDouble(), checkOutLat: (map['check_out_lat'] as num?)?.toDouble(),
checkOutLng: (map['check_out_lng'] as num?)?.toDouble(), checkOutLng: (map['check_out_lng'] as num?)?.toDouble(),
justification: map['justification'] as String?, justification: map['justification'] as String?,
checkOutJustification: map['check_out_justification'] as String?,
verificationStatus: map['verification_status'] as String? ?? 'pending', verificationStatus: map['verification_status'] as String? ?? 'pending',
verificationPhotoUrl: map['verification_photo_url'] as String?, checkInVerificationPhotoUrl:
map['check_in_verification_photo_url'] as String? ?? legacyUrl,
checkOutVerificationPhotoUrl:
map['check_out_verification_photo_url'] as String?,
); );
} }
} }

View File

@ -91,10 +91,16 @@ class AttendanceController {
required String attendanceId, required String attendanceId,
required double lat, required double lat,
required double lng, required double lng,
String? justification,
}) async { }) async {
await _client.rpc( await _client.rpc(
'attendance_check_out', 'attendance_check_out',
params: {'p_attendance_id': attendanceId, 'p_lat': lat, 'p_lng': lng}, params: {
'p_attendance_id': attendanceId,
'p_lat': lat,
'p_lng': lng,
if (justification != null) 'p_justification': justification,
},
); );
} }
@ -118,10 +124,12 @@ class AttendanceController {
required Uint8List bytes, required Uint8List bytes,
required String fileName, required String fileName,
required String status, // 'verified', 'unverified' required String status, // 'verified', 'unverified'
bool isCheckOut = false,
}) async { }) async {
final userId = _client.auth.currentUser!.id; final userId = _client.auth.currentUser!.id;
final ext = fileName.split('.').last.toLowerCase(); final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/$attendanceId.$ext'; final prefix = isCheckOut ? 'checkout' : 'checkin';
final path = '$userId/${prefix}_$attendanceId.$ext';
await _client.storage await _client.storage
.from('attendance-verification') .from('attendance-verification')
.uploadBinary( .uploadBinary(
@ -132,9 +140,12 @@ class AttendanceController {
final url = _client.storage final url = _client.storage
.from('attendance-verification') .from('attendance-verification')
.getPublicUrl(path); .getPublicUrl(path);
final column = isCheckOut
? 'check_out_verification_photo_url'
: 'check_in_verification_photo_url';
await _client await _client
.from('attendance_logs') .from('attendance_logs')
.update({'verification_status': status, 'verification_photo_url': url}) .update({'verification_status': status, column: url})
.eq('id', attendanceId); .eq('id', attendanceId);
} }

View File

@ -102,12 +102,13 @@ class VerificationSessionController {
}) })
.eq('id', session.userId); .eq('id', session.userId);
} else if (session.type == 'verification' && session.contextId != null) { } else if (session.type == 'verification' && session.contextId != null) {
// Update attendance log verification status // Update attendance log verification status.
// Cross-device verification defaults to check-in photo column.
await _client await _client
.from('attendance_logs') .from('attendance_logs')
.update({ .update({
'verification_status': 'verified', 'verification_status': 'verified',
'verification_photo_url': session.imageUrl, 'check_in_verification_photo_url': session.imageUrl,
}) })
.eq('id', session.contextId!); .eq('id', session.contextId!);
} }

View File

@ -912,12 +912,46 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
accuracy: LocationAccuracy.high, accuracy: LocationAccuracy.high,
), ),
); );
// Check if outside geofence require justification if so
String? checkOutJustification;
final geoCfg = await ref.read(geofenceProvider.future);
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
if (geoCfg != null && !debugBypass) {
bool inside = false;
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
if (!inside && mounted) {
checkOutJustification = await _showCheckOutJustificationDialog(
context,
);
if (checkOutJustification == null) {
// User cancelled
return;
}
}
}
await ref await ref
.read(attendanceControllerProvider) .read(attendanceControllerProvider)
.checkOut( .checkOut(
attendanceId: log.id, attendanceId: log.id,
lat: position.latitude, lat: position.latitude,
lng: position.longitude, lng: position.longitude,
justification: checkOutJustification,
); );
// Update live position immediately on check-out // Update live position immediately on check-out
ref.read(whereaboutsControllerProvider).updatePositionNow(); ref.read(whereaboutsControllerProvider).updatePositionNow();
@ -930,7 +964,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
_overtimeLogId = null; _overtimeLogId = null;
}); });
showSuccessSnackBar(context, 'Checked out! Running verification...'); showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(log.id); _performFaceVerification(log.id, isCheckOut: true);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -960,12 +994,46 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
accuracy: LocationAccuracy.high, accuracy: LocationAccuracy.high,
), ),
); );
// Check if outside geofence require justification if so
String? checkOutJustification;
final geoCfg = await ref.read(geofenceProvider.future);
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
if (geoCfg != null && !debugBypass) {
bool inside = false;
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
if (!inside && mounted) {
checkOutJustification = await _showCheckOutJustificationDialog(
context,
);
if (checkOutJustification == null) {
// User cancelled
return;
}
}
}
await ref await ref
.read(attendanceControllerProvider) .read(attendanceControllerProvider)
.checkOut( .checkOut(
attendanceId: logId, attendanceId: logId,
lat: position.latitude, lat: position.latitude,
lng: position.longitude, lng: position.longitude,
justification: checkOutJustification,
); );
// Update live position immediately on check-out // Update live position immediately on check-out
ref.read(whereaboutsControllerProvider).updatePositionNow(); ref.read(whereaboutsControllerProvider).updatePositionNow();
@ -978,7 +1046,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
_overtimeLogId = null; _overtimeLogId = null;
}); });
showSuccessSnackBar(context, 'Checked out! Running verification...'); showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(logId); _performFaceVerification(logId, isCheckOut: true);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -989,6 +1057,61 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
} }
} }
/// Shows a dialog asking for justification when checking out outside geofence.
/// Returns the justification text, or null if the user cancelled.
Future<String?> _showCheckOutJustificationDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await m3ShowDialog<String>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
final textTheme = Theme.of(ctx).textTheme;
return AlertDialog(
icon: Icon(Icons.location_off, color: colors.error),
title: const Text('Outside Geofence'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'You are checking out outside the designated area. '
'Please provide a justification.',
style: textTheme.bodyMedium,
),
const SizedBox(height: 16),
TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Justification',
hintText:
'Explain why you are checking out outside the geofence...',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty) return;
Navigator.of(ctx).pop(text);
},
child: const Text('Submit & Check Out'),
),
],
);
},
);
controller.dispose();
return result;
}
Future<void> _handleOvertimeCheckIn() async { Future<void> _handleOvertimeCheckIn() async {
final justification = _justificationController.text.trim(); final justification = _justificationController.text.trim();
if (justification.isEmpty) { if (justification.isEmpty) {
@ -1060,7 +1183,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
/// Face verification after check-in/out: liveness detection on mobile, /// Face verification after check-in/out: liveness detection on mobile,
/// camera/gallery on web. Uploads selfie and updates attendance log. /// camera/gallery on web. Uploads selfie and updates attendance log.
Future<void> _performFaceVerification(String attendanceLogId) async { Future<void> _performFaceVerification(
String attendanceLogId, {
bool isCheckOut = false,
}) async {
final profile = ref.read(currentProfileProvider).valueOrNull; final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null || !profile.hasFaceEnrolled) { if (profile == null || !profile.hasFaceEnrolled) {
try { try {
@ -1081,6 +1207,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
context: context, context: context,
ref: ref, ref: ref,
attendanceLogId: attendanceLogId, attendanceLogId: attendanceLogId,
isCheckOut: isCheckOut,
); );
if (!mounted) return; if (!mounted) return;
@ -1364,7 +1491,10 @@ class _LogbookEntry {
this.logId, this.logId,
this.logUserId, this.logUserId,
this.enrolledFaceUrl, this.enrolledFaceUrl,
this.verificationFaceUrl, this.checkInVerificationFaceUrl,
this.checkOutVerificationFaceUrl,
this.justification,
this.checkOutJustification,
this.checkInLat, this.checkInLat,
this.checkInLng, this.checkInLng,
this.checkOutLat, this.checkOutLat,
@ -1385,7 +1515,10 @@ class _LogbookEntry {
final String? logId; final String? logId;
final String? logUserId; final String? logUserId;
final String? enrolledFaceUrl; final String? enrolledFaceUrl;
final String? verificationFaceUrl; final String? checkInVerificationFaceUrl;
final String? checkOutVerificationFaceUrl;
final String? justification;
final String? checkOutJustification;
final double? checkInLat; final double? checkInLat;
final double? checkInLng; final double? checkInLng;
final double? checkOutLat; final double? checkOutLat;
@ -1426,7 +1559,10 @@ class _LogbookEntry {
logId: log.id, logId: log.id,
logUserId: log.userId, logUserId: log.userId,
enrolledFaceUrl: p?.facePhotoUrl, enrolledFaceUrl: p?.facePhotoUrl,
verificationFaceUrl: log.verificationPhotoUrl, checkInVerificationFaceUrl: log.checkInVerificationPhotoUrl,
checkOutVerificationFaceUrl: log.checkOutVerificationPhotoUrl,
justification: log.justification,
checkOutJustification: log.checkOutJustification,
checkInLat: log.checkInLat, checkInLat: log.checkInLat,
checkInLng: log.checkInLng, checkInLng: log.checkInLng,
checkOutLat: log.checkOutLat, checkOutLat: log.checkOutLat,
@ -2059,6 +2195,11 @@ class _VerificationDetailsContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final hasJustification =
(entry.justification ?? '').trim().isNotEmpty ||
(entry.checkOutJustification ?? '').trim().isNotEmpty;
return DefaultTabController( return DefaultTabController(
length: 2, length: 2,
@ -2073,9 +2214,14 @@ class _VerificationDetailsContent extends StatelessWidget {
Text( Text(
'Shift: ${entry.shift}', 'Shift: ${entry.shift}',
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: colors.onSurfaceVariant,
), ),
), ),
// Justification section
if (hasJustification) ...[
const SizedBox(height: 12),
_JustificationSection(entry: entry),
],
const SizedBox(height: 12), const SizedBox(height: 12),
const TabBar( const TabBar(
tabs: [ tabs: [
@ -2109,38 +2255,223 @@ class _VerificationDetailsContent extends StatelessWidget {
} }
} }
class _FaceVerificationTab extends StatelessWidget { /// Displays justification notes for overtime check-in and/or off-site checkout.
class _JustificationSection extends StatelessWidget {
const _JustificationSection({required this.entry});
final _LogbookEntry entry;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final overtimeJustification = (entry.justification ?? '').trim();
final checkOutJustification = (entry.checkOutJustification ?? '').trim();
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.notes_rounded, size: 16, color: colors.tertiary),
const SizedBox(width: 6),
Text(
'Justification',
style: textTheme.labelMedium?.copyWith(
color: colors.tertiary,
fontWeight: FontWeight.w600,
),
),
],
),
if (overtimeJustification.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Overtime:',
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(overtimeJustification, style: textTheme.bodySmall),
],
if (checkOutJustification.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Check-out (outside geofence):',
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(checkOutJustification, style: textTheme.bodySmall),
],
],
),
);
}
}
class _FaceVerificationTab extends StatefulWidget {
const _FaceVerificationTab({required this.entry}); const _FaceVerificationTab({required this.entry});
final _LogbookEntry entry; final _LogbookEntry entry;
@override
State<_FaceVerificationTab> createState() => _FaceVerificationTabState();
}
class _FaceVerificationTabState extends State<_FaceVerificationTab> {
late final PageController _pageController;
int _currentPage = 0;
static const _labels = ['Check-In Verification', 'Check-Out Verification'];
@override
void initState() {
super.initState();
_pageController = PageController();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final entry = widget.entry;
return Column(
children: [
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (i) => setState(() => _currentPage = i),
children: [
// Page 1: Enrolled Face + Check-In Verification
_SideBySidePanel(
enrolledFaceUrl: entry.enrolledFaceUrl,
verificationUrl: entry.checkInVerificationFaceUrl,
verificationLabel: 'Check-In Verification',
verificationIcon: Icons.login_rounded,
emptyMessage: 'No check-in verification photo.',
),
// Page 2: Enrolled Face + Check-Out Verification
_SideBySidePanel(
enrolledFaceUrl: entry.enrolledFaceUrl,
verificationUrl: entry.checkOutVerificationFaceUrl,
verificationLabel: 'Check-Out Verification',
verificationIcon: Icons.logout_rounded,
emptyMessage: 'No check-out verification photo.',
),
],
),
),
const SizedBox(height: 12),
// Label
AnimatedSwitcher(
duration: M3Motion.micro,
child: Text(
_labels[_currentPage],
key: ValueKey(_currentPage),
style: textTheme.labelMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
const SizedBox(height: 8),
// Page indicator dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_labels.length, (i) {
final isActive = i == _currentPage;
return GestureDetector(
onTap: () => _pageController.animateToPage(
i,
duration: M3Motion.standard,
curve: M3Motion.standard_,
),
child: AnimatedContainer(
duration: M3Motion.short,
curve: M3Motion.standard_,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? colors.primary
: colors.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
);
}),
),
const SizedBox(height: 4),
],
);
}
}
/// Side-by-side panel: enrolled face on the left, verification photo on the right.
class _SideBySidePanel extends StatelessWidget {
const _SideBySidePanel({
required this.enrolledFaceUrl,
required this.verificationUrl,
required this.verificationLabel,
required this.verificationIcon,
required this.emptyMessage,
});
final String? enrolledFaceUrl;
final String? verificationUrl;
final String verificationLabel;
final IconData verificationIcon;
final String emptyMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final panelWidth = constraints.maxWidth >= 760 final panelWidth = constraints.maxWidth >= 760
? (constraints.maxWidth - 12) / 2 ? (constraints.maxWidth - 20) / 2
: 300.0; : (constraints.maxWidth - 20) / 2;
return SingleChildScrollView( return Padding(
scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row( child: Row(
children: [ children: [
_ImagePanel( Expanded(
width: panelWidth, child: _ImagePanel(
title: 'Enrolled Face', width: panelWidth,
imageUrl: entry.enrolledFaceUrl, title: 'Enrolled Face',
bucket: 'face-enrollment', imageUrl: enrolledFaceUrl,
emptyMessage: 'No enrolled face photo found.', bucket: 'face-enrollment',
icon: Icons.person, emptyMessage: 'No enrolled face photo found.',
icon: Icons.person,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 8),
_ImagePanel( Expanded(
width: panelWidth, child: _ImagePanel(
title: 'Attendance Verification Face', width: panelWidth,
imageUrl: entry.verificationFaceUrl, title: verificationLabel,
bucket: 'attendance-verification', imageUrl: verificationUrl,
emptyMessage: 'No attendance verification photo found.', bucket: 'attendance-verification',
icon: Icons.verified_user, emptyMessage: emptyMessage,
icon: verificationIcon,
),
), ),
], ],
), ),

View File

@ -38,6 +38,7 @@ Future<FaceVerificationResult?> showFaceVerificationOverlay({
String? attendanceLogId, String? attendanceLogId,
int maxAttempts = 3, int maxAttempts = 3,
bool uploadAttendanceResult = true, bool uploadAttendanceResult = true,
bool isCheckOut = false,
}) { }) {
return Navigator.of(context).push<FaceVerificationResult>( return Navigator.of(context).push<FaceVerificationResult>(
PageRouteBuilder( PageRouteBuilder(
@ -46,6 +47,7 @@ Future<FaceVerificationResult?> showFaceVerificationOverlay({
attendanceLogId: attendanceLogId, attendanceLogId: attendanceLogId,
maxAttempts: maxAttempts, maxAttempts: maxAttempts,
uploadAttendanceResult: uploadAttendanceResult, uploadAttendanceResult: uploadAttendanceResult,
isCheckOut: isCheckOut,
), ),
transitionsBuilder: (ctx, anim, secAnim, child) { transitionsBuilder: (ctx, anim, secAnim, child) {
return FadeTransition(opacity: anim, child: child); return FadeTransition(opacity: anim, child: child);
@ -61,11 +63,13 @@ class _FaceVerificationOverlay extends ConsumerStatefulWidget {
required this.attendanceLogId, required this.attendanceLogId,
required this.maxAttempts, required this.maxAttempts,
required this.uploadAttendanceResult, required this.uploadAttendanceResult,
this.isCheckOut = false,
}); });
final String? attendanceLogId; final String? attendanceLogId;
final int maxAttempts; final int maxAttempts;
final bool uploadAttendanceResult; final bool uploadAttendanceResult;
final bool isCheckOut;
@override @override
ConsumerState<_FaceVerificationOverlay> createState() => ConsumerState<_FaceVerificationOverlay> createState() =>
@ -250,6 +254,7 @@ class _FaceVerificationOverlayState
bytes: compressed, bytes: compressed,
fileName: 'verification.jpg', fileName: 'verification.jpg',
status: status, status: status,
isCheckOut: widget.isCheckOut,
); );
} catch (_) {} } catch (_) {}
} }

View File

@ -0,0 +1,73 @@
-- Split single verification_photo_url into separate check-in and check-out columns.
-- Also add check_out_justification for off-site checkout.
-- Add new columns
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'attendance_logs' AND column_name = 'check_in_verification_photo_url'
) THEN
ALTER TABLE attendance_logs ADD COLUMN check_in_verification_photo_url TEXT;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'attendance_logs' AND column_name = 'check_out_verification_photo_url'
) THEN
ALTER TABLE attendance_logs ADD COLUMN check_out_verification_photo_url TEXT;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'attendance_logs' AND column_name = 'check_out_justification'
) THEN
ALTER TABLE attendance_logs ADD COLUMN check_out_justification TEXT;
END IF;
END $$;
-- Migrate existing data: copy verification_photo_url → check_in_verification_photo_url
-- (the old column stored the last verification photo, which was overwritten by checkout;
-- for rows that were only checked in, this is the check-in photo)
UPDATE attendance_logs
SET check_in_verification_photo_url = verification_photo_url
WHERE verification_photo_url IS NOT NULL
AND check_in_verification_photo_url IS NULL;
-- Update checkout RPC to accept optional justification
CREATE OR REPLACE FUNCTION public.attendance_check_out(
p_attendance_id uuid,
p_lat double precision,
p_lng double precision,
p_justification text DEFAULT NULL
) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_log attendance_logs%ROWTYPE;
BEGIN
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Attendance log not found';
END IF;
IF v_log.user_id != auth.uid() THEN
RAISE EXCEPTION 'Not your attendance log';
END IF;
IF v_log.check_out_at IS NOT NULL THEN
RAISE EXCEPTION 'Already checked out';
END IF;
UPDATE attendance_logs
SET check_out_at = now(),
check_out_lat = p_lat,
check_out_lng = p_lng,
check_out_justification = p_justification
WHERE id = p_attendance_id;
END;
$$;

View File

@ -0,0 +1,37 @@
-- ───────────────────────────────────────────────────────────
-- Fix storage SELECT policies so admin, dispatcher, and it_staff
-- can view any user's face-enrollment and attendance-verification photos.
-- Regular users can still only view their own.
-- ───────────────────────────────────────────────────────────
-- face-enrollment: owner OR privileged roles can view
DROP POLICY IF EXISTS "Users can view own face" ON storage.objects;
CREATE POLICY "Users can view own face"
ON storage.objects FOR SELECT
USING (
bucket_id = 'face-enrollment'
AND (
(storage.foldername(name))[1] = auth.uid()::text
OR EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid()
AND role IN ('admin', 'dispatcher', 'it_staff')
)
)
);
-- attendance-verification: owner OR privileged roles can view
DROP POLICY IF EXISTS "Users and admins can view verification photos" ON storage.objects;
CREATE POLICY "Users and admins can view verification photos"
ON storage.objects FOR SELECT
USING (
bucket_id = 'attendance-verification'
AND (
(storage.foldername(name))[1] = auth.uid()::text
OR EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid()
AND role IN ('admin', 'dispatcher', 'it_staff')
)
)
);