uodate create/detail - no upload file

This commit is contained in:
Phuoc Nguyen
2025-11-28 16:38:46 +07:00
parent 9e7bda32f2
commit 5e3e1401c1
8 changed files with 834 additions and 342 deletions

View File

@@ -71,3 +71,38 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
]
}
}
#create new design request
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.design_request.create' \
--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 '{
"subject": "Nhà phố 2 tầng",
"area": "150",
"region": "Quận 1, TP.HCM",
"desired_style": "Hiện đại",
"estimated_budget": "500 triệu",
"detailed_requirements": "Cần thiết kế phòng khách rộng, 3 phòng ngủ",
"dateline": "2025-12-31"
}'
#response
{
"message": {
"success": true,
"data": {
"name": "ISS-2025-00006"
}
}
}
#upload file
curl --location 'https://land.dbiz.com//api/method/upload_file' \
--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' \
--form 'file=@"/C:/Users/tiennld/Downloads/b0d6423a04ce8890d1df.jpg"' \
--form 'is_private="0"' \
--form 'folder="Home/Attachments"' \
--form 'doctype="Issue"' \
--form 'docname="ISS-2025-00005"' \
--form 'optimize="true"'

View File

@@ -49,6 +49,8 @@
font-weight: 600;
text-transform: uppercase;
display: inline-block;
background: #d1fae5;
color: #065f46;
}
.status-pending {
@@ -66,32 +68,93 @@
color: #065f46;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
/* Description List Styles */
.description-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-item {
text-align: center;
padding: 16px 12px;
background: #f8fafc;
border-radius: 8px;
.description-item {
display: flex;
border-bottom: 1px solid #f3f4f6;
padding-bottom: 12px;
}
.info-label {
font-size: 12px;
.description-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.description-label {
flex-shrink: 0;
width: 120px;
font-size: 13px;
color: #6b7280;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 4px;
font-weight: 500;
padding-top: 2px;
}
.info-value {
font-size: 16px;
font-weight: 700;
.description-value {
flex: 1;
font-size: 15px;
color: #1f2937;
font-weight: 500;
line-height: 2;
}
/* Floor Plan Styles */
.floor-plan-container {
margin-top: 12px;
}
.floor-plan-thumbnail {
position: relative;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s ease;
}
.floor-plan-thumbnail:hover {
transform: translateY(-4px);
}
.floor-plan-image {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
}
.floor-plan-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 91, 154, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
color: white;
}
.floor-plan-thumbnail:hover .floor-plan-overlay {
opacity: 1;
}
.floor-plan-overlay i {
font-size: 32px;
}
.floor-plan-overlay span {
font-size: 14px;
font-weight: 600;
}
.detail-section {
@@ -302,17 +365,13 @@
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
gap: 12px;
.description-item {
flex-direction: column;
gap: 4px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
padding: 12px 16px;
.description-label {
width: auto;
}
.action-buttons {
@@ -343,27 +402,31 @@
<span class="status-badge" id="status-badge">Hoàn thành</span>
</div>
<!-- Project Info Grid -->
<div class="info-grid">
<div class="info-item">
<div class="info-label">Diện tích</div>
<div class="info-value" id="project-area">120m²</div>
</div>
<div class="info-item">
<div class="info-label">Phong cách</div>
<div class="info-value" id="project-style">Hiện đại</div>
</div>
</div>
<!-- Project Info - Simple Description List -->
<div class="detail-section" style="margin-bottom: 0;">
<dl class="description-list">
<div class="info-grid">
<div class="info-item">
<div class="info-label">Ngân sách</div>
<div class="info-value" id="project-budget">300-500 triệu</div>
</div>
<div class="info-item">
<div class="info-label">Trạng thái</div>
<div class="info-value" id="project-status">Đã hoàn thành</div>
</div>
<div class="detail-section">
<h3 class="section-title">
<i class="fas fa-info-circle" style="color: #2563eb;"></i>
Thông tin thiết kế
</h3>
<dl class="description-list">
<div class="description-item">
<dt class="description-label">Tên công trình:</dt>
<dd class="description-value" id="project-name">Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)</dd>
</div>
<div class="description-item">
<dt class="description-label">Mô tả chi tiết:</dt>
<dd class="description-value" id="project-notes">
Diện tích: 85 m² <br>
Khu vực: Hồ Chí Minh <br>
Phong cách mong muốn: Hiện đại <br>
Ngân sách dự kiến: Trao đổi trực tiếp <br>
Yêu cầu chi tiết: Thiết kế với 4 phòng ngủ, 3 phòng tắm, phòng khách rộng rãi và khu bếp mở. Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng.
</div>
</div>
</div>
</div>
@@ -379,40 +442,8 @@
</button>
</div>
<!-- Project Details -->
<!-- Floor Plan Image -->
<div class="detail-card">
<div class="detail-section">
<h3 class="section-title">
<i class="fas fa-info-circle" style="color: #2563eb;"></i>
Thông tin dự án
</h3>
<div class="section-content">
<p><strong>Tên dự án:</strong> <span id="project-name">Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)</span></p>
</div>
</div>
<div class="detail-section">
<h3 class="section-title">
<i class="fas fa-edit" style="color: #2563eb;"></i>
Mô tả yêu cầu
</h3>
<div class="section-content" id="project-description">
Thiết kế nhà phố 3 tầng phong cách hiện đại với 4 phòng ngủ, 3 phòng tắm, phòng khách rộng rãi và khu bếp mở.
Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng. Tầng 1: garage, phòng khách, bếp.
Tầng 2: 2 phòng ngủ, 2 phòng tắm. Tầng 3: phòng ngủ master, phòng làm việc, sân thượng.
</div>
</div>
<div class="detail-section">
<h3 class="section-title">
<i class="fas fa-phone" style="color: #2563eb;"></i>
Thông tin liên hệ
</h3>
<div class="section-content" id="contact-info">
SĐT: 0901234567 | Email: minh.nguyen@email.com
</div>
</div>
<div class="detail-section">
<h3 class="section-title">
<i class="fas fa-paperclip" style="color: #2563eb;"></i>
@@ -440,7 +471,7 @@
</div>
<!-- Status Timeline -->
<div class="detail-card">
<!--<div class="detail-card">
<h3 class="section-title">
<i class="fas fa-history" style="color: #2563eb;"></i>
Lịch sử trạng thái
@@ -491,14 +522,14 @@
</div>
</div>
</div>
</div>
</div>-->
<!-- Action Buttons -->
<div class="action-buttons">
<button class="btn btn-secondary" onclick="editRequest()">
<!--<button class="btn btn-secondary" onclick="editRequest()">
<i class="fas fa-edit"></i>
Chỉnh sửa
</button>
</button>-->
<button class="btn btn-primary" onclick="contactSupport()">
<i class="fas fa-comments"></i>
Liên hệ
@@ -536,28 +567,26 @@
const requestDatabase = {
'YC001': {
id: 'YC001',
status: 'completed',
statusText: 'Đã hoàn thành',
name: 'Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)',
area: '120m²',
style: 'Hiện đại',
budget: '300-500 triệu',
status: 'completed',
statusText: 'Đã hoàn thành',
description: 'Thiết kế nhà phố 3 tầng phong cách hiện đại với 4 phòng ngủ, 3 phòng tắm, phòng khách rộng rãi và khu bếp mở. Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng. Tầng 1: garage, phòng khách, bếp. Tầng 2: 2 phòng ngủ, 2 phòng tắm. Tầng 3: phòng ngủ master, phòng làm việc, sân thượng.',
contact: 'SĐT: 0901234567 | Email: minh.nguyen@email.com',
notes: 'Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng.',
createdDate: '20/10/2024',
files: ['mat-bang-hien-tai.jpg', 'ban-ve-kien-truc.dwg'],
designLink: 'https://example.com/3d-design/YC001'
},
'YC002': {
id: 'YC002',
status: 'designing',
statusText: 'Đang thiết kế',
name: 'Cải tạo căn hộ chung cư - Chị Lan (Quận 2)',
area: '85m²',
style: 'Scandinavian',
budget: '100-300 triệu',
status: 'designing',
statusText: 'Đang thiết kế',
description: 'Cải tạo căn hộ chung cư 3PN theo phong cách Scandinavian. Tối ưu không gian lưu trữ, sử dụng gạch men màu sáng và gỗ tự nhiên.',
contact: 'SĐT: 0987654321',
notes: 'Tối ưu không gian lưu trữ, sử dụng gạch men màu sáng và gỗ tự nhiên.',
createdDate: '25/10/2024',
files: ['hinh-anh-hien-trang.jpg'],
designLink: null
@@ -565,13 +594,12 @@
'YC003': {
id: 'YC003',
name: 'Thiết kế biệt thự 2 tầng - Anh Đức (Bình Dương)',
status: 'pending',
statusText: 'Chờ tiếp nhận',
area: '200m²',
style: 'Luxury',
budget: 'Trên 1 tỷ',
status: 'pending',
statusText: 'Chờ tiếp nhận',
description: 'Thiết kế biệt thự 2 tầng phong cách luxury với hồ bơi và sân vườn. 5 phòng ngủ, 4 phòng tắm, phòng giải trí và garage 2 xe.',
contact: 'SĐT: 0923456789 | Email: duc.le@gmail.com',
notes: 'Thiết kế biệt thự có hồ bơi và sân vườn, 5 phòng ngủ, garage 2 xe.',
createdDate: '28/10/2024',
files: ['mat-bang-dat.pdf', 'y-tuong-thiet-ke.jpg'],
designLink: null
@@ -615,10 +643,8 @@
document.getElementById('project-name').textContent = request.name;
document.getElementById('project-area').textContent = request.area;
document.getElementById('project-style').textContent = request.style;
document.getElementById('project-budget').textContent = request.budget;
document.getElementById('project-status').textContent = request.statusText;
document.getElementById('project-description').textContent = request.description;
document.getElementById('contact-info').textContent = request.contact;
document.getElementById('project-budget').textContent = request.budget + ' VNĐ';
document.getElementById('project-notes').textContent = request.notes || 'Không có ghi chú đặc biệt';
// Update status badge
const statusBadge = document.getElementById('status-badge');
@@ -633,8 +659,7 @@
completionHighlight.style.display = 'none';
}
// Update files list
updateFilesList(request.files);
// Floor plan image - removed files list
// Update page title
document.title = `${request.id} - Chi tiết Yêu cầu Thiết kế`;
@@ -643,37 +668,12 @@
window.currentDesignLink = request.designLink;
}
function updateFilesList(files) {
const filesList = document.getElementById('files-list');
if (!files || files.length === 0) {
filesList.innerHTML = '<p style="color: #6b7280; font-style: italic;">Không có tài liệu đính kèm</p>';
return;
function viewFloorPlan() {
// In real app, open lightbox or full-screen image viewer
const img = document.querySelector('.floor-plan-image');
if (img && img.src) {
window.open(img.src, '_blank');
}
filesList.innerHTML = files.map(fileName => {
const fileIcon = getFileIcon(fileName);
return `
<div class="file-item">
<div class="file-icon">
<i class="${fileIcon}"></i>
</div>
<div class="file-info">
<div class="file-name">${fileName}</div>
</div>
</div>
`;
}).join('');
}
function getFileIcon(fileName) {
const extension = fileName.toLowerCase().split('.').pop();
if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) return 'fas fa-image';
if (extension === 'pdf') return 'fas fa-file-pdf';
if (extension === 'dwg') return 'fas fa-drafting-compass';
if (['doc', 'docx'].includes(extension)) return 'fas fa-file-word';
return 'fas fa-file';
}
function viewDesign3D() {

View File

@@ -356,6 +356,13 @@ class ApiConstants {
static const String getDesignRequestDetail =
'/building_material.building_material.api.design_request.get_detail';
/// Create a new design request (requires sid and csrf_token)
/// POST /api/method/building_material.building_material.api.design_request.create
/// Body: { "subject": "...", "area": "...", "region": "...", "desired_style": "...", "estimated_budget": "...", "detailed_requirements": "...", "dateline": "..." }
/// Returns: { "message": { "success": true, "data": { "name": "ISS-2025-00006" } } }
static const String createDesignRequest =
'/building_material.building_material.api.design_request.create';
/// Create new project (legacy endpoint - may be deprecated)
/// POST /projects
static const String createProject = '/projects';

View File

@@ -3,6 +3,7 @@
/// Handles remote API calls for design requests.
library;
import 'package:dio/dio.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/dio_client.dart';
import 'package:worker/features/showrooms/data/models/design_request_model.dart';
@@ -17,6 +18,24 @@ abstract class DesignRequestRemoteDataSource {
/// Fetch detail of a design request by name
Future<DesignRequestModel> getDesignRequestDetail(String name);
/// Create a new design request
Future<String> createDesignRequest({
required String subject,
required String area,
required String region,
required String desiredStyle,
String? estimatedBudget,
required String detailedRequirements,
required String dateline,
});
/// Upload file attachment for a design request
Future<void> uploadDesignRequestFile({
required String requestId,
required String filePath,
required String fileName,
});
}
/// Design Request Remote Data Source Implementation
@@ -93,4 +112,94 @@ class DesignRequestRemoteDataSourceImpl implements DesignRequestRemoteDataSource
throw Exception('Failed to get design request detail: $e');
}
}
/// Create a new design request
///
/// Calls: POST /api/method/building_material.building_material.api.design_request.create
/// Body: { "subject": "...", "area": "...", "region": "...", ... }
/// Returns: Created request ID (name)
@override
Future<String> createDesignRequest({
required String subject,
required String area,
required String region,
required String desiredStyle,
String? estimatedBudget,
required String detailedRequirements,
required String dateline,
}) async {
try {
final response = await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.createDesignRequest}',
data: {
'subject': subject,
'area': area,
'region': region,
'desired_style': desiredStyle,
if (estimatedBudget != null) 'estimated_budget': estimatedBudget,
'detailed_requirements': detailedRequirements,
'dateline': dateline,
},
);
final data = response.data;
if (data == null) {
throw Exception('No data received from createDesignRequest API');
}
// API returns: { "message": { "success": true, "data": { "name": "ISS-2025-00006" } } }
final message = data['message'] as Map<String, dynamic>?;
if (message == null) {
throw Exception('No message field in createDesignRequest response');
}
final success = message['success'] as bool? ?? false;
if (!success) {
throw Exception('Failed to create design request');
}
final requestData = message['data'] as Map<String, dynamic>?;
if (requestData == null) {
throw Exception('No data field in createDesignRequest response');
}
final name = requestData['name'] as String?;
if (name == null || name.isEmpty) {
throw Exception('No name returned from createDesignRequest');
}
return name;
} catch (e) {
throw Exception('Failed to create design request: $e');
}
}
/// Upload file attachment for a design request
///
/// Calls: POST /api/method/upload_file
/// Form-data: { file, is_private, folder, doctype, docname, optimize }
@override
Future<void> uploadDesignRequestFile({
required String requestId,
required String filePath,
required String fileName,
}) async {
try {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: fileName),
'is_private': '0',
'folder': 'Home/Attachments',
'doctype': 'Issue',
'docname': requestId,
'optimize': 'true',
});
await _dioClient.post<Map<String, dynamic>>(
'${ApiConstants.frappeApiMethod}${ApiConstants.uploadFile}',
data: formData,
);
} catch (e) {
throw Exception('Failed to upload file: $e');
}
}
}

View File

@@ -38,4 +38,46 @@ class DesignRequestRepositoryImpl implements DesignRequestRepository {
rethrow;
}
}
@override
Future<String> createDesignRequest({
required String subject,
required String area,
required String region,
required String desiredStyle,
String? estimatedBudget,
required String detailedRequirements,
required String dateline,
}) async {
try {
return await _remoteDataSource.createDesignRequest(
subject: subject,
area: area,
region: region,
desiredStyle: desiredStyle,
estimatedBudget: estimatedBudget,
detailedRequirements: detailedRequirements,
dateline: dateline,
);
} catch (e) {
rethrow;
}
}
@override
Future<void> uploadDesignRequestFile({
required String requestId,
required String filePath,
required String fileName,
}) async {
try {
await _remoteDataSource.uploadDesignRequestFile(
requestId: requestId,
filePath: filePath,
fileName: fileName,
);
} catch (e) {
rethrow;
}
}
}

View File

@@ -23,4 +23,24 @@ abstract class DesignRequestRepository {
///
/// Returns full design request detail with files_list.
Future<DesignRequest> getDesignRequestDetail(String name);
/// Create a new design request
///
/// Returns created request ID (name).
Future<String> createDesignRequest({
required String subject,
required String area,
required String region,
required String desiredStyle,
String? estimatedBudget,
required String detailedRequirements,
required String dateline,
});
/// Upload file attachment for a design request
Future<void> uploadDesignRequestFile({
required String requestId,
required String filePath,
required String fileName,
});
}

View File

@@ -3,13 +3,17 @@
/// Form to create a new design request following html/design-request-create.html.
library;
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/showrooms/presentation/providers/design_request_provider.dart';
/// Design Request Create Page
///
@@ -28,11 +32,14 @@ class DesignRequestCreatePage extends HookConsumerWidget {
final areaController = useTextEditingController();
final locationController = useTextEditingController();
final notesController = useTextEditingController();
final datelineController = useTextEditingController();
final selectedStyle = useState<String>('');
final selectedBudget = useState<String>('');
final selectedDateline = useState<DateTime?>(null);
final selectedFiles = useState<List<PlatformFile>>([]);
final isSubmitting = useState(false);
final submissionStatus = useState<String>('');
Future<void> pickFiles() async {
try {
@@ -80,14 +87,115 @@ class DesignRequestCreatePage extends HookConsumerWidget {
selectedFiles.value = files;
}
Future<void> submitForm() async {
if (formKey.currentState?.validate() ?? false) {
isSubmitting.value = true;
Future<void> pickDateline() async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDateline.value ?? DateTime.now().add(const Duration(days: 30)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
locale: const Locale('vi', 'VN'),
);
if (picked != null) {
selectedDateline.value = picked;
datelineController.text = DateFormat('dd/MM/yyyy').format(picked);
}
}
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
/// Get style display text for API
String getStyleText(String value) {
switch (value) {
case 'hien-dai':
return 'Hiện đại';
case 'toi-gian':
return 'Tối giản';
case 'co-dien':
return 'Cổ điển';
case 'scandinavian':
return 'Scandinavian';
case 'industrial':
return 'Industrial';
case 'tropical':
return 'Tropical';
case 'luxury':
return 'Luxury';
case 'khac':
return 'Khác';
default:
return value;
}
}
/// Get budget display text for API
String getBudgetText(String value) {
switch (value) {
case 'duoi-100tr':
return 'Dưới 100 triệu';
case '100-300tr':
return '100 - 300 triệu';
case '300-500tr':
return '300 - 500 triệu';
case '500tr-1ty':
return '500 triệu - 1 tỷ';
case 'tren-1ty':
return 'Trên 1 tỷ';
case 'trao-doi':
return 'Trao đổi trực tiếp';
default:
return value;
}
}
Future<void> submitForm() async {
if (!(formKey.currentState?.validate() ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng kiểm tra lại thông tin'),
backgroundColor: AppColors.danger,
),
);
return;
}
isSubmitting.value = true;
submissionStatus.value = 'Đang tạo yêu cầu...';
try {
// Get repository
final repository = await ref.read(designRequestRepositoryProvider.future);
// Step 1: Create design request
final requestId = await repository.createDesignRequest(
subject: projectNameController.text.trim(),
area: areaController.text.trim(),
region: locationController.text.trim(),
desiredStyle: getStyleText(selectedStyle.value),
estimatedBudget: selectedBudget.value.isNotEmpty
? getBudgetText(selectedBudget.value)
: null,
detailedRequirements: notesController.text.trim(),
dateline: DateFormat('yyyy-MM-dd').format(selectedDateline.value!),
);
// Step 2: Upload files if any
if (selectedFiles.value.isNotEmpty) {
submissionStatus.value = 'Đang tải lên tệp đính kèm...';
for (int i = 0; i < selectedFiles.value.length; i++) {
final file = selectedFiles.value[i];
submissionStatus.value = 'Đang tải lên tệp ${i + 1}/${selectedFiles.value.length}...';
if (file.path != null) {
await repository.uploadDesignRequestFile(
requestId: requestId,
filePath: file.path!,
fileName: file.name,
);
}
}
}
isSubmitting.value = false;
submissionStatus.value = '';
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -98,19 +206,23 @@ class DesignRequestCreatePage extends HookConsumerWidget {
);
// Navigate back
Future.delayed(const Duration(milliseconds: 500), () {
if (context.mounted) {
context.pop();
}
});
context.pop();
// Refresh the list (fire and forget)
unawaited(ref.read(designRequestsListProvider.notifier).refresh());
}
} catch (e) {
isSubmitting.value = false;
submissionStatus.value = '';
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: AppColors.danger,
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vui lòng kiểm tra lại thông tin'),
backgroundColor: AppColors.danger,
),
);
}
}
@@ -140,33 +252,10 @@ class DesignRequestCreatePage extends HookConsumerWidget {
key: formKey,
child: Column(
children: [
// Progress Steps
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ProgressStep(number: 1, isActive: true),
Container(
width: 16,
height: 2,
color: AppColors.grey100,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
_ProgressStep(number: 2),
Container(
width: 16,
height: 2,
color: AppColors.grey100,
margin: const EdgeInsets.symmetric(horizontal: 8),
),
_ProgressStep(number: 3),
],
),
const SizedBox(height: 24),
// Basic Information Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -428,6 +517,72 @@ class DesignRequestCreatePage extends HookConsumerWidget {
),
],
),
const SizedBox(height: 20),
// Dateline
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: const TextSpan(
text: 'Thời hạn mong muốn',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
children: [
TextSpan(
text: ' *',
style: TextStyle(color: AppColors.danger),
),
],
),
),
const SizedBox(height: 8),
TextFormField(
controller: datelineController,
readOnly: true,
onTap: pickDateline,
decoration: InputDecoration(
hintText: 'Chọn ngày',
suffixIcon: const Icon(Icons.calendar_today, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.grey100,
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.grey100,
width: 2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Vui lòng chọn thời hạn';
}
return null;
},
),
],
),
],
),
),
@@ -438,6 +593,7 @@ class DesignRequestCreatePage extends HookConsumerWidget {
// Detailed Requirements Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -536,6 +692,8 @@ class DesignRequestCreatePage extends HookConsumerWidget {
// File Upload Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -634,8 +792,8 @@ class DesignRequestCreatePage extends HookConsumerWidget {
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
disabledBackgroundColor: AppColors.primaryBlue.withValues(alpha: 0.7),
disabledForegroundColor: AppColors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -643,13 +801,28 @@ class DesignRequestCreatePage extends HookConsumerWidget {
),
),
child: isSubmitting.value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
),
const SizedBox(width: 12),
Text(
submissionStatus.value.isNotEmpty
? submissionStatus.value
: 'Đang xử lý...',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -678,45 +851,6 @@ class DesignRequestCreatePage extends HookConsumerWidget {
}
/// Progress Step Widget
class _ProgressStep extends StatelessWidget {
final int number;
final bool isActive;
final bool isCompleted;
const _ProgressStep({
required this.number,
this.isActive = false,
this.isCompleted = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? AppColors.success
: isActive
? AppColors.primaryBlue
: AppColors.grey100,
),
child: Center(
child: Text(
'$number',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isActive || isCompleted
? AppColors.white
: AppColors.grey500,
),
),
),
);
}
}
/// Form Field Widget
class _FormField extends StatelessWidget {

View File

@@ -1,6 +1,6 @@
/// Page: Design Request Detail Page
///
/// Displays design request details from API.
/// Displays design request details from API following html/design-request-detail.html.
library;
import 'package:cached_network_image/cached_network_image.dart';
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
@@ -19,7 +20,8 @@ import 'package:worker/features/showrooms/presentation/providers/design_request_
///
/// Shows complete details of a design request including:
/// - Request header with ID, date, and status
/// - Subject and description
/// - Design information (subject, description)
/// - Completion highlight for completed requests
/// - Attached files/images
class DesignRequestDetailPage extends ConsumerWidget {
const DesignRequestDetailPage({required this.requestId, super.key});
@@ -29,7 +31,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
Color _getStatusColor(DesignRequestStatus status) {
switch (status) {
case DesignRequestStatus.pending:
return const Color(0xFFffc107);
return const Color(0xFFd97706);
case DesignRequestStatus.designing:
return const Color(0xFF3730a3);
case DesignRequestStatus.completed:
@@ -117,6 +119,36 @@ class DesignRequestDetailPage extends ConsumerWidget {
}
}
Future<void> _viewDesign3D(BuildContext context) async {
// TODO: Replace with actual design link from API when available
const designUrl = 'https://example.com/3d-design';
try {
final uri = Uri.parse(designUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link thiết kế 3D chưa sẵn sàng'),
backgroundColor: AppColors.warning,
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Lỗi khi mở link: $e'),
backgroundColor: AppColors.danger,
),
);
}
}
}
void _showImageViewer(
BuildContext context,
List<ProjectFile> files,
@@ -174,7 +206,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Request Header Card
// Request Header & Design Info Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
@@ -185,46 +217,97 @@ class DesignRequestDetailPage extends ConsumerWidget {
width: double.infinity,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Request ID
Text(
'#${request.id}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
// Header Section (centered)
Center(
child: Column(
children: [
// Request ID
Text(
'#${request.id}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
// Date
if (request.dateline != null)
Text(
'Ngày gửi: ${request.dateline}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
const SizedBox(height: 16),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: _getStatusBackgroundColor(request.status),
borderRadius: BorderRadius.circular(20),
),
child: Text(
request.statusText.toUpperCase(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _getStatusColor(request.status),
),
),
),
],
),
),
const SizedBox(height: 8),
if (request.dateline != null)
Text(
'Deadline: ${request.dateline}',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
const SizedBox(height: 24),
const Divider(height: 1, color: AppColors.grey100),
const SizedBox(height: 24),
// Design Information Section
Row(
children: [
Icon(
Icons.info_outline,
color: AppColors.primaryBlue,
size: 20,
),
),
const SizedBox(width: 8),
const Text(
'Thông tin thiết kế',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 16),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: _getStatusBackgroundColor(request.status),
borderRadius: BorderRadius.circular(20),
),
child: Text(
request.statusText.toUpperCase(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _getStatusColor(request.status),
),
),
// Description List
_DescriptionItem(
label: 'Tên công trình:',
value: request.subject,
),
if (request.plainDescription.isNotEmpty) ...[
const SizedBox(height: 12),
_DescriptionItem(
label: 'Mô tả chi tiết:',
value: request.plainDescription,
isMultiLine: true,
),
],
],
),
),
@@ -251,23 +334,43 @@ class DesignRequestDetailPage extends ConsumerWidget {
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(24),
child: const Column(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'🎉 Yêu cầu đã hoàn thành!',
const Text(
'🎉 Thiết kế đã hoàn thành!',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Color(0xFF065f46),
),
),
SizedBox(height: 12),
Text(
'Thiết kế của bạn đã sẵn sàng',
const SizedBox(height: 12),
const Text(
'Thiết kế 3D của bạn đã sẵn sàng để xem',
style: TextStyle(color: Color(0xFF065f46)),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _viewDesign3D(context),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10b981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.view_in_ar, size: 20),
label: const Text(
'Xem Link Thiết kế 3D',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
),
@@ -294,7 +397,7 @@ class DesignRequestDetailPage extends ConsumerWidget {
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(20),
child: const Column(
children: [
Text(
@@ -318,63 +421,46 @@ class DesignRequestDetailPage extends ConsumerWidget {
if (request.isRejected) const SizedBox(height: 20),
// Project Details Card
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Subject
_SectionHeader(icon: Icons.info, title: 'Tiêu đề'),
const SizedBox(height: 12),
Text(
request.subject,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
height: 1.6,
),
),
if (request.plainDescription.isNotEmpty) ...[
const SizedBox(height: 24),
// Description
_SectionHeader(icon: Icons.edit, title: 'Mô tả yêu cầu'),
const SizedBox(height: 12),
Text(
request.plainDescription,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
height: 1.6,
),
),
],
// Files
if (request.filesList.isNotEmpty) ...[
const SizedBox(height: 24),
_SectionHeader(
icon: Icons.attach_file,
title: 'Tài liệu đính kèm (${request.filesList.length})',
// Attached Files Card
if (request.filesList.isNotEmpty)
Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.attach_file,
color: AppColors.primaryBlue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Tài liệu đính kèm',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
),
),
],
),
const SizedBox(height: 16),
_buildFilesSection(context, request.filesList),
],
],
),
),
),
),
const SizedBox(height: 20),
if (request.filesList.isNotEmpty) const SizedBox(height: 20),
// Action Button
SizedBox(
@@ -389,9 +475,9 @@ class DesignRequestDetailPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.chat_bubble),
icon: const Icon(Icons.chat_bubble_outline),
label: const Text(
'Liên hệ hỗ trợ',
'Liên hệ',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
@@ -487,40 +573,99 @@ class DesignRequestDetailPage extends ConsumerWidget {
if (otherFiles.isNotEmpty) const SizedBox(height: 16),
],
// Other Files
// Other Files as file items
...otherFiles.map(
(file) => _FileItem(
fileUrl: file.fileUrl,
icon: _getFileIcon(file.fileUrl),
),
),
// Show image files as file items too (for download/naming)
if (images.isNotEmpty && otherFiles.isEmpty)
...images.map(
(file) => _FileItem(
fileUrl: file.fileUrl,
icon: Icons.image,
),
),
],
);
}
}
/// Section Header Widget
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.icon, required this.title});
/// Description Item Widget
class _DescriptionItem extends StatelessWidget {
const _DescriptionItem({
required this.label,
required this.value,
this.isMultiLine = false,
});
final IconData icon;
final String title;
final String label;
final String value;
final bool isMultiLine;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: AppColors.primaryBlue, size: 20),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
if (isMultiLine) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 15,
color: AppColors.grey900,
fontWeight: FontWeight.w500,
height: 1.6,
),
),
],
);
}
return Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.grey100)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontSize: 13,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 15,
color: AppColors.grey900,
fontWeight: FontWeight.w500,
height: 1.6,
),
),
),
],
),
);
}
}
@@ -543,7 +688,7 @@ class _FileItem extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.grey50,
color: AppColors.grey100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -555,7 +700,7 @@ class _FileItem extends StatelessWidget {
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: Colors.white, size: 16),
child: Icon(icon, color: Colors.white, size: 14),
),
const SizedBox(width: 12),
Expanded(
@@ -577,14 +722,14 @@ class _FileItem extends StatelessWidget {
/// Image Viewer Dialog with Swipe Navigation
class _ImageViewerDialog extends StatefulWidget {
final List<ProjectFile> images;
final int initialIndex;
const _ImageViewerDialog({
required this.images,
required this.initialIndex,
});
final List<ProjectFile> images;
final int initialIndex;
@override
State<_ImageViewerDialog> createState() => _ImageViewerDialogState();
}