submission

This commit is contained in:
Phuoc Nguyen
2025-11-27 17:58:13 +07:00
parent b6cb9e865a
commit 6e7e848ad6
15 changed files with 745 additions and 109 deletions

View File

@@ -139,3 +139,44 @@ curl --location 'https://land.dbiz.com//api/method/upload_file' \
--form 'docname="p9ti8veq2g"' \ --form 'docname="p9ti8veq2g"' \
--form 'optimize="true"' --form 'optimize="true"'
#get detail of a project
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.project.get_detail' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"name": "#DA00011"
}'
#response
{
"message": {
"success": true,
"data": {
"name": "#DA00011",
"designed_area": "f67gg7",
"address_of_project": "7fucuv",
"project_owner": "cycu",
"design_firm": null,
"contruction_contractor": null,
"design_area": 2585.0,
"products_included_in_the_design": "thy",
"project_progress": "k1mr565o91",
"expected_commencement_date": "2025-11-30",
"description": null,
"request_date": "2025-11-27 16:51:54",
"workflow_state": "Pending approval",
"reason_for_rejection": null,
"status": "Chờ phê duyệt",
"status_color": "Warning",
"is_allow_modify": true,
"is_allow_cancel": true,
"files_list": [
{
"name": "0068d2403c",
"file_url": "https://land.dbiz.com/private/files/image_picker_32BD79E6-7A71-448E-A5DF-6DA7D12A1303-66894-000015E4259DBB5B.png"
}
]
}
}
}

View File

@@ -308,6 +308,13 @@ class ApiConstants {
static const String saveProject = static const String saveProject =
'/building_material.building_material.api.project.save'; '/building_material.building_material.api.project.save';
/// Get project detail (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.project.get_detail
/// Body: { "name": "#DA00011" }
/// Returns: Full project detail with all fields
static const String getProjectDetail =
'/building_material.building_material.api.project.get_detail';
/// Create new project (legacy endpoint - may be deprecated) /// Create new project (legacy endpoint - may be deprecated)
/// POST /projects /// POST /projects
static const String createProject = '/projects'; static const String createProject = '/projects';

View File

@@ -189,7 +189,7 @@ final class LoggingInterceptorProvider
} }
String _$loggingInterceptorHash() => String _$loggingInterceptorHash() =>
r'4d3147e9084d261e14653386ecd74ee471993af4'; r'6afa480caa6fcd723dab769bb01601b8a37e20fd';
/// Provider for ErrorTransformerInterceptor /// Provider for ErrorTransformerInterceptor

View File

@@ -42,6 +42,7 @@ import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_page.dart'; import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/products/presentation/pages/write_review_page.dart'; import 'package:worker/features/products/presentation/pages/write_review_page.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/features/projects/presentation/pages/submission_create_page.dart'; import 'package:worker/features/projects/presentation/pages/submission_create_page.dart';
import 'package:worker/features/projects/presentation/pages/submissions_page.dart'; import 'package:worker/features/projects/presentation/pages/submissions_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart'; import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
@@ -391,12 +392,17 @@ final routerProvider = Provider<GoRouter>((ref) {
MaterialPage(key: state.pageKey, child: const SubmissionsPage()), MaterialPage(key: state.pageKey, child: const SubmissionsPage()),
), ),
// Submission Create Route // Submission Create/Edit Route
GoRoute( GoRoute(
path: RouteNames.submissionCreate, path: RouteNames.submissionCreate,
name: RouteNames.submissionCreate, name: RouteNames.submissionCreate,
pageBuilder: (context, state) => pageBuilder: (context, state) {
MaterialPage(key: state.pageKey, child: const SubmissionCreatePage()), final submission = state.extra as ProjectSubmission?;
return MaterialPage(
key: state.pageKey,
child: SubmissionCreatePage(submission: submission),
);
},
), ),
// Quotes Route // Quotes Route

View File

@@ -27,6 +27,10 @@ abstract class SubmissionsRemoteDataSource {
int limitPageLength = 0, int limitPageLength = 0,
}); });
/// Fetch project detail by name
/// Returns the full project detail as a model
Future<ProjectSubmissionModel> getSubmissionDetail(String name);
/// Create or update a project submission /// Create or update a project submission
/// Returns the project name (ID) from the API response /// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request); Future<String> saveSubmission(ProjectSubmissionRequest request);
@@ -170,6 +174,42 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
} }
} }
/// Get project detail by name
///
/// Calls: POST /api/method/building_material.building_material.api.project.get_detail
/// Body: { "name": "#DA00011" }
/// Response: { "message": { "success": true, "data": {...} } }
/// Returns: Full project detail as model
@override
Future<ProjectSubmissionModel> getSubmissionDetail(String name) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.getProjectDetail}',
data: {'name': name},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from getProjectDetail API');
}
// API returns: { "message": { "success": true, "data": {...} } }
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in getProjectDetail response');
}
final detailData = message['data'] as Map<String, dynamic>?;
if (detailData == null) {
throw Exception('No data field in getProjectDetail response');
}
return ProjectSubmissionModel.fromJson(detailData);
} catch (e) {
throw Exception('Failed to get project detail: $e');
}
}
/// Save (create/update) a project submission /// Save (create/update) a project submission
/// ///
/// Calls: POST /api/method/building_material.building_material.api.project.save /// Calls: POST /api/method/building_material.building_material.api.project.save
@@ -227,7 +267,7 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
final fileName = filePath.split('/').last; final fileName = filePath.split('/').last;
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: fileName), 'file': await MultipartFile.fromFile(filePath, filename: fileName),
'is_private': '1', 'is_private': '0',
'folder': 'Home/Attachments', 'folder': 'Home/Attachments',
'doctype': 'Architectural Project', 'doctype': 'Architectural Project',
'docname': projectName, 'docname': projectName,

View File

@@ -1,47 +1,117 @@
/// Project Submission Model /// Project Submission Model
/// ///
/// Data model for project submission from API responses with Hive caching. /// Data model for project submission from API responses.
/// Based on API response from building_material.building_material.api.project.get_list /// Based on API response from building_material.building_material.api.project.get_detail
library; library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart'; import 'package:worker/features/projects/domain/entities/project_submission.dart';
part 'project_submission_model.g.dart'; /// Project File Model
class ProjectFileModel {
/// Unique file identifier (API: name)
final String id;
/// Project Submission Model - Type ID: 14 /// Full URL to the file (API: file_url)
@HiveType(typeId: HiveTypeIds.projectSubmissionModel) final String fileUrl;
class ProjectSubmissionModel extends HiveObject {
const ProjectFileModel({
required this.id,
required this.fileUrl,
});
/// Create from JSON (API response)
factory ProjectFileModel.fromJson(Map<String, dynamic> json) {
return ProjectFileModel(
id: json['name'] as String,
fileUrl: json['file_url'] as String,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'name': id,
'file_url': fileUrl,
};
}
/// Convert to entity
ProjectFile toEntity() {
return ProjectFile(
id: id,
fileUrl: fileUrl,
);
}
/// Create from entity
factory ProjectFileModel.fromEntity(ProjectFile entity) {
return ProjectFileModel(
id: entity.id,
fileUrl: entity.fileUrl,
);
}
}
/// Project Submission Model
class ProjectSubmissionModel {
/// Unique submission identifier (API: name) /// Unique submission identifier (API: name)
@HiveField(0)
final String submissionId; final String submissionId;
/// Project name/title (API: designed_area) /// Project name/title (API: designed_area)
@HiveField(1)
final String designedArea; final String designedArea;
/// Design area value in square meters (API: design_area) /// Design area value in square meters (API: design_area)
@HiveField(2)
final double designArea; final double designArea;
/// Submission/request date (API: request_date) /// Submission/request date (API: request_date)
@HiveField(3)
final DateTime requestDate; final DateTime requestDate;
/// Status label - Vietnamese (API: status) /// Status label - Vietnamese (API: status)
@HiveField(4)
final String status; final String status;
/// Rejection reason if rejected (API: reason_for_rejection) /// Rejection reason if rejected (API: reason_for_rejection)
@HiveField(5)
final String? reasonForRejection; final String? reasonForRejection;
/// Status color indicator (API: status_color) /// Status color indicator (API: status_color)
@HiveField(6)
final String statusColor; final String statusColor;
ProjectSubmissionModel({ /// Project address (API: address_of_project)
final String? addressOfProject;
/// Project owner name (API: project_owner)
final String? projectOwner;
/// Design firm name (API: design_firm)
final String? designFirm;
/// Construction contractor name (API: contruction_contractor)
final String? constructionContractor;
/// Products included in the design (API: products_included_in_the_design)
final String? productsIncludedInTheDesign;
/// Project progress ID reference (API: project_progress)
final String? projectProgress;
/// Expected commencement date (API: expected_commencement_date)
final DateTime? expectedCommencementDate;
/// Project description (API: description)
final String? description;
/// Workflow state (API: workflow_state)
final String? workflowState;
/// Whether the submission can be modified (API: is_allow_modify)
final bool isAllowModify;
/// Whether the submission can be cancelled (API: is_allow_cancel)
final bool isAllowCancel;
/// List of attached files (API: files_list)
final List<ProjectFileModel> filesList;
const ProjectSubmissionModel({
required this.submissionId, required this.submissionId,
required this.designedArea, required this.designedArea,
required this.designArea, required this.designArea,
@@ -49,10 +119,39 @@ class ProjectSubmissionModel extends HiveObject {
required this.status, required this.status,
this.reasonForRejection, this.reasonForRejection,
required this.statusColor, required this.statusColor,
this.addressOfProject,
this.projectOwner,
this.designFirm,
this.constructionContractor,
this.productsIncludedInTheDesign,
this.projectProgress,
this.expectedCommencementDate,
this.description,
this.workflowState,
this.isAllowModify = false,
this.isAllowCancel = false,
this.filesList = const [],
}); });
/// Create from JSON (API response) /// Create from JSON (API response)
/// Handles both list response and detail response formats
factory ProjectSubmissionModel.fromJson(Map<String, dynamic> json) { factory ProjectSubmissionModel.fromJson(Map<String, dynamic> json) {
// Parse expected_commencement_date
DateTime? expectedDate;
final expectedDateStr = json['expected_commencement_date'] as String?;
if (expectedDateStr != null && expectedDateStr.isNotEmpty) {
try {
expectedDate = DateTime.parse(expectedDateStr);
} catch (_) {}
}
// Parse files_list
final filesListJson = json['files_list'] as List<dynamic>?;
final filesList = filesListJson
?.map((f) => ProjectFileModel.fromJson(f as Map<String, dynamic>))
.toList() ??
[];
return ProjectSubmissionModel( return ProjectSubmissionModel(
submissionId: json['name'] as String, submissionId: json['name'] as String,
designedArea: json['designed_area'] as String, designedArea: json['designed_area'] as String,
@@ -61,6 +160,19 @@ class ProjectSubmissionModel extends HiveObject {
status: json['status'] as String, status: json['status'] as String,
reasonForRejection: json['reason_for_rejection'] as String?, reasonForRejection: json['reason_for_rejection'] as String?,
statusColor: json['status_color'] as String, statusColor: json['status_color'] as String,
addressOfProject: json['address_of_project'] as String?,
projectOwner: json['project_owner'] as String?,
designFirm: json['design_firm'] as String?,
constructionContractor: json['contruction_contractor'] as String?,
productsIncludedInTheDesign:
json['products_included_in_the_design'] as String?,
projectProgress: json['project_progress'] as String?,
expectedCommencementDate: expectedDate,
description: json['description'] as String?,
workflowState: json['workflow_state'] as String?,
isAllowModify: json['is_allow_modify'] as bool? ?? false,
isAllowCancel: json['is_allow_cancel'] as bool? ?? false,
filesList: filesList,
); );
} }
@@ -74,6 +186,19 @@ class ProjectSubmissionModel extends HiveObject {
'status': status, 'status': status,
'reason_for_rejection': reasonForRejection, 'reason_for_rejection': reasonForRejection,
'status_color': statusColor, 'status_color': statusColor,
'address_of_project': addressOfProject,
'project_owner': projectOwner,
'design_firm': designFirm,
'contruction_contractor': constructionContractor,
'products_included_in_the_design': productsIncludedInTheDesign,
'project_progress': projectProgress,
'expected_commencement_date':
expectedCommencementDate?.toIso8601String(),
'description': description,
'workflow_state': workflowState,
'is_allow_modify': isAllowModify,
'is_allow_cancel': isAllowCancel,
'files_list': filesList.map((f) => f.toJson()).toList(),
}; };
} }
@@ -87,6 +212,18 @@ class ProjectSubmissionModel extends HiveObject {
status: status, status: status,
reasonForRejection: reasonForRejection, reasonForRejection: reasonForRejection,
statusColor: statusColor, statusColor: statusColor,
addressOfProject: addressOfProject,
projectOwner: projectOwner,
designFirm: designFirm,
constructionContractor: constructionContractor,
productsIncludedInTheDesign: productsIncludedInTheDesign,
projectProgress: projectProgress,
expectedCommencementDate: expectedCommencementDate,
description: description,
workflowState: workflowState,
isAllowModify: isAllowModify,
isAllowCancel: isAllowCancel,
filesList: filesList.map((f) => f.toEntity()).toList(),
); );
} }
@@ -100,6 +237,19 @@ class ProjectSubmissionModel extends HiveObject {
status: entity.status, status: entity.status,
reasonForRejection: entity.reasonForRejection, reasonForRejection: entity.reasonForRejection,
statusColor: entity.statusColor, statusColor: entity.statusColor,
addressOfProject: entity.addressOfProject,
projectOwner: entity.projectOwner,
designFirm: entity.designFirm,
constructionContractor: entity.constructionContractor,
productsIncludedInTheDesign: entity.productsIncludedInTheDesign,
projectProgress: entity.projectProgress,
expectedCommencementDate: entity.expectedCommencementDate,
description: entity.description,
workflowState: entity.workflowState,
isAllowModify: entity.isAllowModify,
isAllowCancel: entity.isAllowCancel,
filesList:
entity.filesList.map((f) => ProjectFileModel.fromEntity(f)).toList(),
); );
} }
} }

View File

@@ -1,60 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'project_submission_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ProjectSubmissionModelAdapter
extends TypeAdapter<ProjectSubmissionModel> {
@override
final typeId = 14;
@override
ProjectSubmissionModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ProjectSubmissionModel(
submissionId: fields[0] as String,
designedArea: fields[1] as String,
designArea: (fields[2] as num).toDouble(),
requestDate: fields[3] as DateTime,
status: fields[4] as String,
reasonForRejection: fields[5] as String?,
statusColor: fields[6] as String,
);
}
@override
void write(BinaryWriter writer, ProjectSubmissionModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.submissionId)
..writeByte(1)
..write(obj.designedArea)
..writeByte(2)
..write(obj.designArea)
..writeByte(3)
..write(obj.requestDate)
..writeByte(4)
..write(obj.status)
..writeByte(5)
..write(obj.reasonForRejection)
..writeByte(6)
..write(obj.statusColor);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProjectSubmissionModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -138,6 +138,16 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository {
} }
} }
@override
Future<ProjectSubmission> getSubmissionDetail(String name) async {
try {
final model = await _remoteDataSource.getSubmissionDetail(name);
return model.toEntity();
} catch (e) {
rethrow;
}
}
@override @override
Future<String> saveSubmission(ProjectSubmissionRequest request) async { Future<String> saveSubmission(ProjectSubmissionRequest request) async {
try { try {

View File

@@ -1,22 +1,53 @@
/// Domain Entity: Project Submission /// Domain Entity: Project Submission
/// ///
/// Represents a completed project submitted for loyalty points. /// Represents a completed project submitted for loyalty points.
/// Based on API response from building_material.building_material.api.project.get_list /// Based on API response from building_material.building_material.api.project.get_detail
library; library;
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// Project File Entity
///
/// Represents an uploaded file attached to a project submission.
class ProjectFile extends Equatable {
/// Unique file identifier (API: name)
final String id;
/// Full URL to the file (API: file_url)
final String fileUrl;
const ProjectFile({
required this.id,
required this.fileUrl,
});
@override
List<Object?> get props => [id, fileUrl];
}
/// Project Submission Entity /// Project Submission Entity
/// ///
/// Contains information about a completed project submission. /// Contains information about a completed project submission.
/// Mapped from API response: /// Mapped from API response:
/// - name -> submissionId /// - name -> submissionId
/// - designed_area -> designedArea (project name/title) /// - designed_area -> designedArea (project name/title)
/// - address_of_project -> addressOfProject
/// - project_owner -> projectOwner
/// - design_firm -> designFirm
/// - contruction_contractor -> constructionContractor
/// - design_area -> designArea (area value in m²) /// - design_area -> designArea (area value in m²)
/// - products_included_in_the_design -> productsIncludedInTheDesign
/// - project_progress -> projectProgress (ID reference)
/// - expected_commencement_date -> expectedCommencementDate
/// - description -> description
/// - request_date -> requestDate /// - request_date -> requestDate
/// - status -> status (Vietnamese label) /// - workflow_state -> workflowState
/// - reason_for_rejection -> reasonForRejection /// - reason_for_rejection -> reasonForRejection
/// - status -> status (Vietnamese label)
/// - status_color -> statusColor /// - status_color -> statusColor
/// - is_allow_modify -> isAllowModify
/// - is_allow_cancel -> isAllowCancel
/// - files_list -> filesList
class ProjectSubmission extends Equatable { class ProjectSubmission extends Equatable {
/// Unique submission identifier (API: name) /// Unique submission identifier (API: name)
final String submissionId; final String submissionId;
@@ -24,31 +55,80 @@ class ProjectSubmission extends Equatable {
/// Project name/title (API: designed_area) /// Project name/title (API: designed_area)
final String designedArea; final String designedArea;
/// Project address (API: address_of_project)
final String? addressOfProject;
/// Project owner name (API: project_owner)
final String? projectOwner;
/// Design firm name (API: design_firm)
final String? designFirm;
/// Construction contractor name (API: contruction_contractor)
final String? constructionContractor;
/// Design area value in square meters (API: design_area) /// Design area value in square meters (API: design_area)
final double designArea; final double designArea;
/// Products included in the design (API: products_included_in_the_design)
final String? productsIncludedInTheDesign;
/// Project progress ID reference (API: project_progress)
final String? projectProgress;
/// Expected commencement date (API: expected_commencement_date)
final DateTime? expectedCommencementDate;
/// Project description (API: description)
final String? description;
/// Submission/request date (API: request_date) /// Submission/request date (API: request_date)
final DateTime requestDate; final DateTime requestDate;
/// Workflow state (API: workflow_state)
/// e.g., "Pending approval", "Approved", "Rejected", "Cancelled"
final String? workflowState;
/// Rejection reason if rejected (API: reason_for_rejection)
final String? reasonForRejection;
/// Status label - Vietnamese (API: status) /// Status label - Vietnamese (API: status)
/// e.g., "Chờ phê duyệt", "Đã được phê duyệt", "Từ chối", "HỦY BỎ" /// e.g., "Chờ phê duyệt", "Đã được phê duyệt", "Từ chối", "HỦY BỎ"
final String status; final String status;
/// Rejection reason if rejected (API: reason_for_rejection)
final String? reasonForRejection;
/// Status color indicator (API: status_color) /// Status color indicator (API: status_color)
/// Values: "Warning", "Success", "Danger" /// Values: "Warning", "Success", "Danger"
final String statusColor; final String statusColor;
/// Whether the submission can be modified (API: is_allow_modify)
final bool isAllowModify;
/// Whether the submission can be cancelled (API: is_allow_cancel)
final bool isAllowCancel;
/// List of attached files (API: files_list)
final List<ProjectFile> filesList;
const ProjectSubmission({ const ProjectSubmission({
required this.submissionId, required this.submissionId,
required this.designedArea, required this.designedArea,
this.addressOfProject,
this.projectOwner,
this.designFirm,
this.constructionContractor,
required this.designArea, required this.designArea,
this.productsIncludedInTheDesign,
this.projectProgress,
this.expectedCommencementDate,
this.description,
required this.requestDate, required this.requestDate,
required this.status, this.workflowState,
this.reasonForRejection, this.reasonForRejection,
required this.status,
required this.statusColor, required this.statusColor,
this.isAllowModify = false,
this.isAllowCancel = false,
this.filesList = const [],
}); });
/// Check if submission is pending approval /// Check if submission is pending approval
@@ -64,20 +144,44 @@ class ProjectSubmission extends Equatable {
ProjectSubmission copyWith({ ProjectSubmission copyWith({
String? submissionId, String? submissionId,
String? designedArea, String? designedArea,
String? addressOfProject,
String? projectOwner,
String? designFirm,
String? constructionContractor,
double? designArea, double? designArea,
String? productsIncludedInTheDesign,
String? projectProgress,
DateTime? expectedCommencementDate,
String? description,
DateTime? requestDate, DateTime? requestDate,
String? status, String? workflowState,
String? reasonForRejection, String? reasonForRejection,
String? status,
String? statusColor, String? statusColor,
bool? isAllowModify,
bool? isAllowCancel,
List<ProjectFile>? filesList,
}) { }) {
return ProjectSubmission( return ProjectSubmission(
submissionId: submissionId ?? this.submissionId, submissionId: submissionId ?? this.submissionId,
designedArea: designedArea ?? this.designedArea, designedArea: designedArea ?? this.designedArea,
addressOfProject: addressOfProject ?? this.addressOfProject,
projectOwner: projectOwner ?? this.projectOwner,
designFirm: designFirm ?? this.designFirm,
constructionContractor: constructionContractor ?? this.constructionContractor,
designArea: designArea ?? this.designArea, designArea: designArea ?? this.designArea,
productsIncludedInTheDesign: productsIncludedInTheDesign ?? this.productsIncludedInTheDesign,
projectProgress: projectProgress ?? this.projectProgress,
expectedCommencementDate: expectedCommencementDate ?? this.expectedCommencementDate,
description: description ?? this.description,
requestDate: requestDate ?? this.requestDate, requestDate: requestDate ?? this.requestDate,
status: status ?? this.status, workflowState: workflowState ?? this.workflowState,
reasonForRejection: reasonForRejection ?? this.reasonForRejection, reasonForRejection: reasonForRejection ?? this.reasonForRejection,
status: status ?? this.status,
statusColor: statusColor ?? this.statusColor, statusColor: statusColor ?? this.statusColor,
isAllowModify: isAllowModify ?? this.isAllowModify,
isAllowCancel: isAllowCancel ?? this.isAllowCancel,
filesList: filesList ?? this.filesList,
); );
} }
@@ -85,11 +189,23 @@ class ProjectSubmission extends Equatable {
List<Object?> get props => [ List<Object?> get props => [
submissionId, submissionId,
designedArea, designedArea,
addressOfProject,
projectOwner,
designFirm,
constructionContractor,
designArea, designArea,
productsIncludedInTheDesign,
projectProgress,
expectedCommencementDate,
description,
requestDate, requestDate,
status, workflowState,
reasonForRejection, reasonForRejection,
status,
statusColor, statusColor,
isAllowModify,
isAllowCancel,
filesList,
]; ];
@override @override

View File

@@ -38,6 +38,10 @@ abstract class SubmissionsRepository {
int limitPageLength = 0, int limitPageLength = 0,
}); });
/// Get project detail by name
/// Returns the full project detail as entity for form prefilling
Future<ProjectSubmission> getSubmissionDetail(String name);
/// Save (create/update) a project submission /// Save (create/update) a project submission
/// Returns the project name (ID) from the API response /// Returns the project name (ID) from the API response
Future<String> saveSubmission(ProjectSubmissionRequest request); Future<String> saveSubmission(ProjectSubmissionRequest request);

View File

@@ -5,6 +5,7 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@@ -13,11 +14,17 @@ import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/projects/data/models/project_submission_request.dart'; import 'package:worker/features/projects/data/models/project_submission_request.dart';
import 'package:worker/features/projects/domain/entities/project_progress.dart'; import 'package:worker/features/projects/domain/entities/project_progress.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/features/projects/presentation/providers/submissions_provider.dart'; import 'package:worker/features/projects/presentation/providers/submissions_provider.dart';
/// Project Submission Create Page /// Project Submission Create/Edit Page
class SubmissionCreatePage extends ConsumerStatefulWidget { class SubmissionCreatePage extends ConsumerStatefulWidget {
const SubmissionCreatePage({super.key}); const SubmissionCreatePage({super.key, this.submission});
/// Optional submission for editing mode
/// If null, creates new submission
/// If provided, prefills form and updates existing submission
final ProjectSubmission? submission;
@override @override
ConsumerState<SubmissionCreatePage> createState() => ConsumerState<SubmissionCreatePage> createState() =>
@@ -40,8 +47,73 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
// Form state // Form state
ProjectProgress? _selectedProgress; ProjectProgress? _selectedProgress;
DateTime? _expectedStartDate; DateTime? _expectedStartDate;
final List<File> _uploadedFiles = []; final List<File> _uploadedFiles = []; // New files to upload
List<ProjectFile> _existingFiles = []; // Existing files from API
bool _isSubmitting = false; bool _isSubmitting = false;
bool _isLoadingDetail = false;
/// Whether we're editing an existing submission
bool get isEditing => widget.submission != null;
@override
void initState() {
super.initState();
// Fetch full detail when editing
if (isEditing) {
_loadDetail();
}
}
/// Load full project detail from API for editing
Future<void> _loadDetail() async {
if (!isEditing) return;
setState(() => _isLoadingDetail = true);
try {
final detail = await ref.read(
submissionDetailProvider(widget.submission!.submissionId).future,
);
if (!mounted) return;
// Prefill form fields from entity
_projectNameController.text = detail.designedArea;
_addressController.text = detail.addressOfProject ?? '';
_ownerController.text = detail.projectOwner ?? '';
_designUnitController.text = detail.designFirm ?? '';
_constructionUnitController.text = detail.constructionContractor ?? '';
_areaController.text = detail.designArea.toString();
_productsController.text = detail.productsIncludedInTheDesign ?? '';
_descriptionController.text = detail.description ?? '';
// Set expected commencement date
_expectedStartDate = detail.expectedCommencementDate;
// Find matching progress from the list
final progressId = detail.projectProgress;
if (progressId != null) {
final progressList = await ref.read(projectProgressListProvider.future);
_selectedProgress = progressList.where((p) => p.id == progressId).firstOrNull;
}
// Set existing files from API
_existingFiles = detail.filesList;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi tải thông tin: $e'),
backgroundColor: AppColors.danger,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoadingDetail = false);
}
}
}
@override @override
void dispose() { void dispose() {
@@ -69,9 +141,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: const Text( title: Text(
'Đăng ký Công trình', isEditing ? 'Chỉnh sửa Dự án' : 'Đăng ký Công trình',
style: TextStyle(color: Colors.black), style: const TextStyle(color: Colors.black),
), ),
actions: [ actions: [
IconButton( IconButton(
@@ -88,7 +160,21 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
backgroundColor: AppColors.white, backgroundColor: AppColors.white,
centerTitle: false, centerTitle: false,
), ),
body: Form( body: _isLoadingDetail
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
'Đang tải thông tin dự án...',
style: TextStyle(color: AppColors.grey500),
),
],
),
)
: Form(
key: _formKey, key: _formKey,
child: ListView( child: ListView(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@@ -322,8 +408,41 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
), ),
// Existing files from API
if (_existingFiles.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Ảnh đã tải lên',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey500,
),
),
const SizedBox(height: 8),
..._existingFiles.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildExistingFileItem(file, index),
);
}),
],
// New files to upload
if (_uploadedFiles.isNotEmpty) ...[ if (_uploadedFiles.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
if (_existingFiles.isNotEmpty)
const Text(
'Ảnh mới',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey500,
),
),
if (_existingFiles.isNotEmpty) const SizedBox(height: 8),
..._uploadedFiles.asMap().entries.map((entry) { ..._uploadedFiles.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final file = entry.value; final file = entry.value;
@@ -737,6 +856,88 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
); );
} }
Widget _buildExistingFileItem(ProjectFile file, int index) {
final fileName = file.fileUrl.split('/').last;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FA),
border: Border.all(color: AppColors.success),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
// Network image
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
imageUrl: file.fileUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 48,
height: 48,
color: AppColors.grey100,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: AppColors.grey100,
child: const Center(
child: FaIcon(
FontAwesomeIcons.image,
size: 24,
color: AppColors.grey500,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
style: const TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.grey900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
const Text(
'Đã tải lên',
style: TextStyle(
fontSize: 12,
color: AppColors.success,
),
),
],
),
),
// Checkmark icon
const FaIcon(
FontAwesomeIcons.circleCheck,
size: 16,
color: AppColors.success,
),
],
),
);
}
Widget _buildSubmitButton() { Widget _buildSubmitButton() {
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
@@ -847,7 +1048,11 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Xác nhận'), title: const Text('Xác nhận'),
content: const Text('Xác nhận gửi đăng ký công trình?'), content: Text(
isEditing
? 'Xác nhận cập nhật thông tin dự án?'
: 'Xác nhận gửi đăng ký công trình?',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
@@ -870,7 +1075,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
final area = double.tryParse(_areaController.text.trim()) ?? 0.0; final area = double.tryParse(_areaController.text.trim()) ?? 0.0;
// Create submission request // Create submission request
// Include name field when editing (for update)
final request = ProjectSubmissionRequest( final request = ProjectSubmissionRequest(
name: isEditing ? widget.submission!.submissionId : null,
designedArea: _projectNameController.text.trim(), designedArea: _projectNameController.text.trim(),
addressOfProject: _addressController.text.trim(), addressOfProject: _addressController.text.trim(),
projectOwner: _ownerController.text.trim(), projectOwner: _ownerController.text.trim(),
@@ -907,9 +1114,11 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text( content: Text(
'Đăng ký công trình đã được gửi thành công!\nChúng tôi sẽ xem xét và liên hệ với bạn sớm nhất.', isEditing
? 'Cập nhật dự án thành công!'
: 'Đăng ký công trình đã được gửi thành công!\nChúng tôi sẽ xem xét và liên hệ với bạn sớm nhất.',
), ),
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
), ),

View File

@@ -171,7 +171,7 @@ class SubmissionsPage extends ConsumerWidget {
itemCount: submissions.length, itemCount: submissions.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final submission = submissions[index]; final submission = submissions[index];
return _buildSubmissionCard(context, submission); return _buildSubmissionCard(context, ref, submission);
}, },
), ),
); );
@@ -260,17 +260,22 @@ class SubmissionsPage extends ConsumerWidget {
); );
} }
Widget _buildSubmissionCard(BuildContext context, ProjectSubmission submission) { Widget _buildSubmissionCard(BuildContext context, WidgetRef ref, ProjectSubmission submission) {
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell( child: InkWell(
onTap: () { onTap: () async {
// TODO: Navigate to submission detail // Navigate to edit submission page
ScaffoldMessenger.of(context).showSnackBar( final result = await context.push<bool>(
SnackBar(content: Text('Chi tiết dự án ${submission.submissionId}')), RouteNames.submissionCreate,
extra: submission,
); );
if (result == true) {
// Refresh submissions list after successful update
ref.invalidate(allSubmissionsProvider);
}
}, },
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(

View File

@@ -193,6 +193,16 @@ AsyncValue<List<ProjectSubmission>> filteredSubmissions(Ref ref) {
}); });
} }
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
@riverpod
Future<ProjectSubmission> submissionDetail(Ref ref, String name) async {
final repository = await ref.watch(submissionsRepositoryProvider.future);
return repository.getSubmissionDetail(name);
}
/// Save Submission Provider /// Save Submission Provider
/// ///
/// Handles creating new project submissions via API. /// Handles creating new project submissions via API.

View File

@@ -569,6 +569,107 @@ final class FilteredSubmissionsProvider
String _$filteredSubmissionsHash() => String _$filteredSubmissionsHash() =>
r'5be22b3242426c6b0c2f9778eaee5c7cf23e4814'; r'5be22b3242426c6b0c2f9778eaee5c7cf23e4814';
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
@ProviderFor(submissionDetail)
const submissionDetailProvider = SubmissionDetailFamily._();
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
final class SubmissionDetailProvider
extends
$FunctionalProvider<
AsyncValue<ProjectSubmission>,
ProjectSubmission,
FutureOr<ProjectSubmission>
>
with
$FutureModifier<ProjectSubmission>,
$FutureProvider<ProjectSubmission> {
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
const SubmissionDetailProvider._({
required SubmissionDetailFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'submissionDetailProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$submissionDetailHash();
@override
String toString() {
return r'submissionDetailProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<ProjectSubmission> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<ProjectSubmission> create(Ref ref) {
final argument = this.argument as String;
return submissionDetail(ref, argument);
}
@override
bool operator ==(Object other) {
return other is SubmissionDetailProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$submissionDetailHash() => r'd3c767aa55e74a36c6a2b9b9bf6dd8ad8bf8eff3';
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
final class SubmissionDetailFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<ProjectSubmission>, String> {
const SubmissionDetailFamily._()
: super(
retry: null,
name: r'submissionDetailProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Submission Detail Provider
///
/// Fetches full project detail by name for editing.
/// Uses family modifier to cache by submission name.
SubmissionDetailProvider call(String name) =>
SubmissionDetailProvider._(argument: name, from: this);
@override
String toString() => r'submissionDetailProvider';
}
/// Save Submission Provider /// Save Submission Provider
/// ///
/// Handles creating new project submissions via API. /// Handles creating new project submissions via API.

View File

@@ -34,7 +34,6 @@ import 'package:worker/features/products/data/models/stock_level_model.dart';
import 'package:worker/features/projects/data/models/design_request_model.dart'; import 'package:worker/features/projects/data/models/design_request_model.dart';
import 'package:worker/features/projects/data/models/project_progress_model.dart'; import 'package:worker/features/projects/data/models/project_progress_model.dart';
import 'package:worker/features/projects/data/models/project_status_model.dart'; import 'package:worker/features/projects/data/models/project_status_model.dart';
import 'package:worker/features/projects/data/models/project_submission_model.dart';
import 'package:worker/features/quotes/data/models/quote_item_model.dart'; import 'package:worker/features/quotes/data/models/quote_item_model.dart';
import 'package:worker/features/quotes/data/models/quote_model.dart'; import 'package:worker/features/quotes/data/models/quote_model.dart';
import 'package:worker/features/showrooms/data/models/showroom_model.dart'; import 'package:worker/features/showrooms/data/models/showroom_model.dart';
@@ -80,7 +79,6 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(ProductModelAdapter()); registerAdapter(ProductModelAdapter());
registerAdapter(ProjectProgressModelAdapter()); registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter()); registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectTypeAdapter()); registerAdapter(ProjectTypeAdapter());
registerAdapter(PromotionModelAdapter()); registerAdapter(PromotionModelAdapter());
registerAdapter(QuoteItemModelAdapter()); registerAdapter(QuoteItemModelAdapter());
@@ -141,7 +139,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(ProductModelAdapter()); registerAdapter(ProductModelAdapter());
registerAdapter(ProjectProgressModelAdapter()); registerAdapter(ProjectProgressModelAdapter());
registerAdapter(ProjectStatusModelAdapter()); registerAdapter(ProjectStatusModelAdapter());
registerAdapter(ProjectSubmissionModelAdapter());
registerAdapter(ProjectTypeAdapter()); registerAdapter(ProjectTypeAdapter());
registerAdapter(PromotionModelAdapter()); registerAdapter(PromotionModelAdapter());
registerAdapter(QuoteItemModelAdapter()); registerAdapter(QuoteItemModelAdapter());