Initial commit: TimeReg Flutter app med Firebase backend

- Opprettet Flutter-prosjekt med alle nødvendige avhengigheter
- Implementert datamodeller (User, TimeRegistration, TariffProfile, Deviation, AuditLog)
- Implementert tjenester (AuthService, TimeService)
- Implementert Riverpod providers for state management
- Opprettet autentiseringsskjermer (login, signup, reset password)
- Opprettet hjemmeskjerm med timer-funksjonalitet
- Opprettet placeholder-skjermer for historikk, rapporter og profil
- Lagt til norsk dokumentasjon i README
This commit is contained in:
steinhelge
2025-11-24 20:52:27 +01:00
commit c829f78984
148 changed files with 8462 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum AuditAction {
create,
update,
delete,
}
enum EntityType {
timeRegistration,
user,
tariffProfile,
}
class AuditLog {
final String id;
final String userId;
final String organizationId;
final AuditAction action;
final EntityType entityType;
final String entityId;
final Map<String, dynamic>? before;
final Map<String, dynamic>? after;
final DateTime timestamp;
final String? ipAddress;
AuditLog({
required this.id,
required this.userId,
required this.organizationId,
required this.action,
required this.entityType,
required this.entityId,
this.before,
this.after,
required this.timestamp,
this.ipAddress,
});
factory AuditLog.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return AuditLog(
id: doc.id,
userId: data['userId'] ?? '',
organizationId: data['organizationId'] ?? '',
action: _parseAction(data['action']),
entityType: _parseEntityType(data['entityType']),
entityId: data['entityId'] ?? '',
before: data['changes']?['before'],
after: data['changes']?['after'],
timestamp: (data['timestamp'] as Timestamp).toDate(),
ipAddress: data['ipAddress'],
);
}
Map<String, dynamic> toFirestore() {
return {
'userId': userId,
'organizationId': organizationId,
'action': action.name,
'entityType': entityType.name,
'entityId': entityId,
'changes': {
'before': before,
'after': after,
},
'timestamp': Timestamp.fromDate(timestamp),
'ipAddress': ipAddress,
};
}
static AuditAction _parseAction(String? actionString) {
switch (actionString) {
case 'update':
return AuditAction.update;
case 'delete':
return AuditAction.delete;
default:
return AuditAction.create;
}
}
static EntityType _parseEntityType(String? typeString) {
switch (typeString) {
case 'user':
return EntityType.user;
case 'tariffProfile':
return EntityType.tariffProfile;
default:
return EntityType.timeRegistration;
}
}
String get actionDisplayName {
switch (action) {
case AuditAction.create:
return 'Opprettet';
case AuditAction.update:
return 'Endret';
case AuditAction.delete:
return 'Slettet';
}
}
String get entityTypeDisplayName {
switch (entityType) {
case EntityType.timeRegistration:
return 'Timeregistrering';
case EntityType.user:
return 'Bruker';
case EntityType.tariffProfile:
return 'Tariffprofil';
}
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum DeviationType {
dailyMax,
weeklyMax,
dailyRest,
weeklyRest,
averageExceeded,
nightWork,
}
enum DeviationSeverity {
warning,
violation,
}
class DeviationMetadata {
final double actualValue;
final double limitValue;
final String? period;
DeviationMetadata({
required this.actualValue,
required this.limitValue,
this.period,
});
factory DeviationMetadata.fromMap(Map<String, dynamic> map) {
return DeviationMetadata(
actualValue: (map['actualValue'] ?? 0).toDouble(),
limitValue: (map['limitValue'] ?? 0).toDouble(),
period: map['period'],
);
}
Map<String, dynamic> toMap() {
return {
'actualValue': actualValue,
'limitValue': limitValue,
'period': period,
};
}
}
class Deviation {
final String id;
final String userId;
final String organizationId;
final String timeRegistrationId;
final DeviationType type;
final DeviationSeverity severity;
final String description;
final DateTime detectedAt;
final DateTime? acknowledgedAt;
final String? acknowledgedBy;
final DeviationMetadata metadata;
Deviation({
required this.id,
required this.userId,
required this.organizationId,
required this.timeRegistrationId,
required this.type,
required this.severity,
required this.description,
required this.detectedAt,
this.acknowledgedAt,
this.acknowledgedBy,
required this.metadata,
});
factory Deviation.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return Deviation(
id: doc.id,
userId: data['userId'] ?? '',
organizationId: data['organizationId'] ?? '',
timeRegistrationId: data['timeRegistrationId'] ?? '',
type: _parseType(data['type']),
severity: _parseSeverity(data['severity']),
description: data['description'] ?? '',
detectedAt: (data['detectedAt'] as Timestamp).toDate(),
acknowledgedAt: data['acknowledgedAt'] != null
? (data['acknowledgedAt'] as Timestamp).toDate()
: null,
acknowledgedBy: data['acknowledgedBy'],
metadata: DeviationMetadata.fromMap(data['metadata'] ?? {}),
);
}
Map<String, dynamic> toFirestore() {
return {
'userId': userId,
'organizationId': organizationId,
'timeRegistrationId': timeRegistrationId,
'type': type.name,
'severity': severity.name,
'description': description,
'detectedAt': Timestamp.fromDate(detectedAt),
'acknowledgedAt':
acknowledgedAt != null ? Timestamp.fromDate(acknowledgedAt!) : null,
'acknowledgedBy': acknowledgedBy,
'metadata': metadata.toMap(),
};
}
static DeviationType _parseType(String? typeString) {
switch (typeString) {
case 'dailyMax':
return DeviationType.dailyMax;
case 'weeklyMax':
return DeviationType.weeklyMax;
case 'dailyRest':
return DeviationType.dailyRest;
case 'weeklyRest':
return DeviationType.weeklyRest;
case 'averageExceeded':
return DeviationType.averageExceeded;
case 'nightWork':
return DeviationType.nightWork;
default:
return DeviationType.dailyMax;
}
}
static DeviationSeverity _parseSeverity(String? severityString) {
switch (severityString) {
case 'violation':
return DeviationSeverity.violation;
default:
return DeviationSeverity.warning;
}
}
String get typeDisplayName {
switch (type) {
case DeviationType.dailyMax:
return 'Maks daglig arbeidstid';
case DeviationType.weeklyMax:
return 'Maks ukentlig arbeidstid';
case DeviationType.dailyRest:
return 'Daglig hviletid';
case DeviationType.weeklyRest:
return 'Ukentlig hviletid';
case DeviationType.averageExceeded:
return 'Gjennomsnittsberegning overskredet';
case DeviationType.nightWork:
return 'Nattarbeid';
}
}
bool get isAcknowledged => acknowledgedAt != null;
Deviation copyWith({
String? userId,
String? organizationId,
String? timeRegistrationId,
DeviationType? type,
DeviationSeverity? severity,
String? description,
DateTime? detectedAt,
DateTime? acknowledgedAt,
String? acknowledgedBy,
DeviationMetadata? metadata,
}) {
return Deviation(
id: id,
userId: userId ?? this.userId,
organizationId: organizationId ?? this.organizationId,
timeRegistrationId: timeRegistrationId ?? this.timeRegistrationId,
type: type ?? this.type,
severity: severity ?? this.severity,
description: description ?? this.description,
detectedAt: detectedAt ?? this.detectedAt,
acknowledgedAt: acknowledgedAt ?? this.acknowledgedAt,
acknowledgedBy: acknowledgedBy ?? this.acknowledgedBy,
metadata: metadata ?? this.metadata,
);
}
}
+195
View File
@@ -0,0 +1,195 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class NightWorkRules {
final int startHour; // e.g., 21 for 9 PM
final int endHour; // e.g., 6 for 6 AM
final int maxNightHours;
NightWorkRules({
required this.startHour,
required this.endHour,
required this.maxNightHours,
});
factory NightWorkRules.fromMap(Map<String, dynamic> map) {
return NightWorkRules(
startHour: map['startHour'] ?? 21,
endHour: map['endHour'] ?? 6,
maxNightHours: map['maxNightHours'] ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
'startHour': startHour,
'endHour': endHour,
'maxNightHours': maxNightHours,
};
}
}
class TariffRules {
final int maxDailyHours;
final int maxWeeklyHours;
final int minDailyRest; // in minutes
final int minWeeklyRest; // in minutes
final bool useAverageCalculation;
final int? averagePeriodWeeks;
final int? maxAverageHours;
final NightWorkRules? nightWorkRules;
TariffRules({
required this.maxDailyHours,
required this.maxWeeklyHours,
required this.minDailyRest,
required this.minWeeklyRest,
this.useAverageCalculation = false,
this.averagePeriodWeeks,
this.maxAverageHours,
this.nightWorkRules,
});
factory TariffRules.fromMap(Map<String, dynamic> map) {
return TariffRules(
maxDailyHours: map['maxDailyHours'] ?? 9,
maxWeeklyHours: map['maxWeeklyHours'] ?? 40,
minDailyRest: map['minDailyRest'] ?? 660, // 11 hours
minWeeklyRest: map['minWeeklyRest'] ?? 2100, // 35 hours
useAverageCalculation: map['useAverageCalculation'] ?? false,
averagePeriodWeeks: map['averagePeriodWeeks'],
maxAverageHours: map['maxAverageHours'],
nightWorkRules: map['nightWorkRules'] != null
? NightWorkRules.fromMap(map['nightWorkRules'])
: null,
);
}
Map<String, dynamic> toMap() {
return {
'maxDailyHours': maxDailyHours,
'maxWeeklyHours': maxWeeklyHours,
'minDailyRest': minDailyRest,
'minWeeklyRest': minWeeklyRest,
'useAverageCalculation': useAverageCalculation,
'averagePeriodWeeks': averagePeriodWeeks,
'maxAverageHours': maxAverageHours,
'nightWorkRules': nightWorkRules?.toMap(),
};
}
}
class TariffProfile {
final String id;
final String name;
final String description;
final String organizationId;
final TariffRules rules;
final bool isDefault;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
TariffProfile({
required this.id,
required this.name,
required this.description,
required this.organizationId,
required this.rules,
this.isDefault = false,
this.isActive = true,
required this.createdAt,
required this.updatedAt,
});
factory TariffProfile.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return TariffProfile(
id: doc.id,
name: data['name'] ?? '',
description: data['description'] ?? '',
organizationId: data['organizationId'] ?? '',
rules: TariffRules.fromMap(data['rules'] ?? {}),
isDefault: data['isDefault'] ?? false,
isActive: data['isActive'] ?? true,
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
);
}
Map<String, dynamic> toFirestore() {
return {
'name': name,
'description': description,
'organizationId': organizationId,
'rules': rules.toMap(),
'isDefault': isDefault,
'isActive': isActive,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
};
}
TariffProfile copyWith({
String? name,
String? description,
String? organizationId,
TariffRules? rules,
bool? isDefault,
bool? isActive,
DateTime? updatedAt,
}) {
return TariffProfile(
id: id,
name: name ?? this.name,
description: description ?? this.description,
organizationId: organizationId ?? this.organizationId,
rules: rules ?? this.rules,
isDefault: isDefault ?? this.isDefault,
isActive: isActive ?? this.isActive,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Predefined profiles
static TariffProfile createDefaultAML(String organizationId) {
return TariffProfile(
id: 'default_aml',
name: 'Ingen tariffavtale (AML Standard)',
description: 'Standard arbeidsmiljøloven uten tariffavtale',
organizationId: organizationId,
rules: TariffRules(
maxDailyHours: 9,
maxWeeklyHours: 40,
minDailyRest: 660, // 11 hours
minWeeklyRest: 2100, // 35 hours
),
isDefault: true,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
static TariffProfile createTariffA(String organizationId) {
return TariffProfile(
id: 'tariff_a',
name: 'Tariffavtale A',
description: 'Tariffavtale med utvidet arbeidstid og gjennomsnittsberegning',
organizationId: organizationId,
rules: TariffRules(
maxDailyHours: 10,
maxWeeklyHours: 48,
minDailyRest: 660, // 11 hours
minWeeklyRest: 2100, // 35 hours
useAverageCalculation: true,
averagePeriodWeeks: 8,
maxAverageHours: 48,
),
isDefault: false,
isActive: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum RegistrationType {
ordinary,
overtime,
oncall,
travel,
}
enum SyncStatus {
synced,
pending,
conflict,
}
class TimeBreak {
final DateTime startTime;
final DateTime endTime;
final int duration; // in minutes
TimeBreak({
required this.startTime,
required this.endTime,
required this.duration,
});
factory TimeBreak.fromMap(Map<String, dynamic> map) {
return TimeBreak(
startTime: (map['startTime'] as Timestamp).toDate(),
endTime: (map['endTime'] as Timestamp).toDate(),
duration: map['duration'] ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
'startTime': Timestamp.fromDate(startTime),
'endTime': Timestamp.fromDate(endTime),
'duration': duration,
};
}
}
class TimeRegistration {
final String id;
final String userId;
final String organizationId;
final DateTime startTime;
final DateTime? endTime;
final int duration; // in minutes
final RegistrationType type;
final String? projectId;
final String? customerId;
final String? comment;
final List<TimeBreak> breaks;
final DateTime createdAt;
final DateTime updatedAt;
final String createdBy;
final String? lastModifiedBy;
final List<String> deviations;
final bool isManual;
final SyncStatus syncStatus;
TimeRegistration({
required this.id,
required this.userId,
required this.organizationId,
required this.startTime,
this.endTime,
required this.duration,
this.type = RegistrationType.ordinary,
this.projectId,
this.customerId,
this.comment,
this.breaks = const [],
required this.createdAt,
required this.updatedAt,
required this.createdBy,
this.lastModifiedBy,
this.deviations = const [],
this.isManual = false,
this.syncStatus = SyncStatus.synced,
});
factory TimeRegistration.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return TimeRegistration(
id: doc.id,
userId: data['userId'] ?? '',
organizationId: data['organizationId'] ?? '',
startTime: (data['startTime'] as Timestamp).toDate(),
endTime: data['endTime'] != null
? (data['endTime'] as Timestamp).toDate()
: null,
duration: data['duration'] ?? 0,
type: _parseType(data['type']),
projectId: data['projectId'],
customerId: data['customerId'],
comment: data['comment'],
breaks: (data['breaks'] as List<dynamic>?)
?.map((b) => TimeBreak.fromMap(b as Map<String, dynamic>))
.toList() ??
[],
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
createdBy: data['createdBy'] ?? '',
lastModifiedBy: data['lastModifiedBy'],
deviations: List<String>.from(data['deviations'] ?? []),
isManual: data['isManual'] ?? false,
syncStatus: _parseSyncStatus(data['syncStatus']),
);
}
Map<String, dynamic> toFirestore() {
return {
'userId': userId,
'organizationId': organizationId,
'startTime': Timestamp.fromDate(startTime),
'endTime': endTime != null ? Timestamp.fromDate(endTime!) : null,
'duration': duration,
'type': type.name,
'projectId': projectId,
'customerId': customerId,
'comment': comment,
'breaks': breaks.map((b) => b.toMap()).toList(),
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'createdBy': createdBy,
'lastModifiedBy': lastModifiedBy,
'deviations': deviations,
'isManual': isManual,
'syncStatus': syncStatus.name,
};
}
static RegistrationType _parseType(String? typeString) {
switch (typeString) {
case 'overtime':
return RegistrationType.overtime;
case 'oncall':
return RegistrationType.oncall;
case 'travel':
return RegistrationType.travel;
default:
return RegistrationType.ordinary;
}
}
static SyncStatus _parseSyncStatus(String? statusString) {
switch (statusString) {
case 'pending':
return SyncStatus.pending;
case 'conflict':
return SyncStatus.conflict;
default:
return SyncStatus.synced;
}
}
int get totalBreakMinutes {
return breaks.fold(0, (sum, b) => sum + b.duration);
}
int get netWorkMinutes {
return duration - totalBreakMinutes;
}
bool get isActive {
return endTime == null;
}
TimeRegistration copyWith({
String? userId,
String? organizationId,
DateTime? startTime,
DateTime? endTime,
int? duration,
RegistrationType? type,
String? projectId,
String? customerId,
String? comment,
List<TimeBreak>? breaks,
DateTime? updatedAt,
String? lastModifiedBy,
List<String>? deviations,
bool? isManual,
SyncStatus? syncStatus,
}) {
return TimeRegistration(
id: id,
userId: userId ?? this.userId,
organizationId: organizationId ?? this.organizationId,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
duration: duration ?? this.duration,
type: type ?? this.type,
projectId: projectId ?? this.projectId,
customerId: customerId ?? this.customerId,
comment: comment ?? this.comment,
breaks: breaks ?? this.breaks,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
createdBy: createdBy,
lastModifiedBy: lastModifiedBy ?? this.lastModifiedBy,
deviations: deviations ?? this.deviations,
isManual: isManual ?? this.isManual,
syncStatus: syncStatus ?? this.syncStatus,
);
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:cloud_firestore/cloud_firestore.dart';
enum UserRole {
employee,
admin,
systemAdmin,
}
class UserModel {
final String uid;
final String email;
final String displayName;
final UserRole role;
final String tariffProfileId;
final String organizationId;
final String? departmentId;
final DateTime createdAt;
final DateTime updatedAt;
final bool isActive;
final UserPreferences preferences;
UserModel({
required this.uid,
required this.email,
required this.displayName,
required this.role,
required this.tariffProfileId,
required this.organizationId,
this.departmentId,
required this.createdAt,
required this.updatedAt,
this.isActive = true,
required this.preferences,
});
factory UserModel.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return UserModel(
uid: doc.id,
email: data['email'] ?? '',
displayName: data['displayName'] ?? '',
role: _parseRole(data['role']),
tariffProfileId: data['tariffProfileId'] ?? '',
organizationId: data['organizationId'] ?? '',
departmentId: data['departmentId'],
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
isActive: data['isActive'] ?? true,
preferences: UserPreferences.fromMap(data['preferences'] ?? {}),
);
}
Map<String, dynamic> toFirestore() {
return {
'email': email,
'displayName': displayName,
'role': role.name,
'tariffProfileId': tariffProfileId,
'organizationId': organizationId,
'departmentId': departmentId,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'isActive': isActive,
'preferences': preferences.toMap(),
};
}
static UserRole _parseRole(String? roleString) {
switch (roleString) {
case 'admin':
return UserRole.admin;
case 'systemAdmin':
return UserRole.systemAdmin;
default:
return UserRole.employee;
}
}
UserModel copyWith({
String? email,
String? displayName,
UserRole? role,
String? tariffProfileId,
String? organizationId,
String? departmentId,
DateTime? updatedAt,
bool? isActive,
UserPreferences? preferences,
}) {
return UserModel(
uid: uid,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
role: role ?? this.role,
tariffProfileId: tariffProfileId ?? this.tariffProfileId,
organizationId: organizationId ?? this.organizationId,
departmentId: departmentId ?? this.departmentId,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isActive: isActive ?? this.isActive,
preferences: preferences ?? this.preferences,
);
}
}
class UserPreferences {
final bool notificationsEnabled;
final bool emailNotifications;
final bool pushNotifications;
UserPreferences({
this.notificationsEnabled = true,
this.emailNotifications = true,
this.pushNotifications = true,
});
factory UserPreferences.fromMap(Map<String, dynamic> map) {
return UserPreferences(
notificationsEnabled: map['notificationsEnabled'] ?? true,
emailNotifications: map['emailNotifications'] ?? true,
pushNotifications: map['pushNotifications'] ?? true,
);
}
Map<String, dynamic> toMap() {
return {
'notificationsEnabled': notificationsEnabled,
'emailNotifications': emailNotifications,
'pushNotifications': pushNotifications,
};
}
UserPreferences copyWith({
bool? notificationsEnabled,
bool? emailNotifications,
bool? pushNotifications,
}) {
return UserPreferences(
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
emailNotifications: emailNotifications ?? this.emailNotifications,
pushNotifications: pushNotifications ?? this.pushNotifications,
);
}
}