add chat and users
This commit is contained in:
120
src/GreenHome.Api/Controllers/DeviceMembersController.cs
Normal file
120
src/GreenHome.Api/Controllers/DeviceMembersController.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using GreenHome.Application;
|
||||||
|
|
||||||
|
namespace GreenHome.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class DeviceMembersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDeviceMemberService _memberService;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly ILogger<DeviceMembersController> _logger;
|
||||||
|
|
||||||
|
public DeviceMembersController(
|
||||||
|
IDeviceMemberService memberService,
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
ILogger<DeviceMembersController> logger)
|
||||||
|
{
|
||||||
|
_memberService = memberService;
|
||||||
|
_environment = environment;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>لیست اعضای تیم یک دستگاه</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<DeviceMemberDto>>> List(
|
||||||
|
[FromQuery] int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _memberService.ListByDeviceAsync(deviceId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>افزودن یا ویرایش عضو تیم</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<DeviceMemberDto>> Upsert(
|
||||||
|
[FromBody] UpsertDeviceMemberRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _memberService.UpsertAsync(request, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error upserting device member");
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>حذف عضو تیم</summary>
|
||||||
|
[HttpDelete]
|
||||||
|
public async Task<ActionResult> Delete(
|
||||||
|
[FromQuery] int deviceId,
|
||||||
|
[FromQuery] int userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _memberService.DeleteAsync(deviceId, userId, cancellationToken);
|
||||||
|
return Ok(new { message = "عضو با موفقیت حذف شد" });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>بررسی مجوز مدیریت تیم</summary>
|
||||||
|
[HttpGet("can-manage")]
|
||||||
|
public async Task<ActionResult<bool>> CanManage(
|
||||||
|
[FromQuery] int actorUserId,
|
||||||
|
[FromQuery] int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var canManage = await _memberService.CanManageDeviceAsync(actorUserId, deviceId, cancellationToken);
|
||||||
|
return Ok(new { canManage });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>آپلود آواتار عضو</summary>
|
||||||
|
[HttpPost("avatar")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<ActionResult<string>> UploadAvatar(
|
||||||
|
[FromForm] IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return BadRequest(new { error = "فایل انتخاب نشده است" });
|
||||||
|
|
||||||
|
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/webp" };
|
||||||
|
if (!allowedTypes.Contains(file.ContentType.ToLower()))
|
||||||
|
return BadRequest(new { error = "فقط فایلهای تصویری مجاز هستند" });
|
||||||
|
|
||||||
|
if (file.Length > 2 * 1024 * 1024)
|
||||||
|
return BadRequest(new { error = "حجم آواتار نباید بیشتر از 2 مگابایت باشد" });
|
||||||
|
|
||||||
|
var uploadsFolder = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "avatars");
|
||||||
|
Directory.CreateDirectory(uploadsFolder);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||||
|
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||||
|
|
||||||
|
await using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = $"/uploads/avatars/{fileName}";
|
||||||
|
return Ok(new { avatarPath = relativePath, message = "آواتار با موفقیت آپلود شد" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error uploading avatar");
|
||||||
|
return StatusCode(500, new { error = "خطا در آپلود آواتار" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,5 +208,85 @@ public class DevicePostsController : ControllerBase
|
|||||||
var hasAccess = await _postService.CanUserAccessDeviceAsync(userId, deviceId, cancellationToken);
|
var hasAccess = await _postService.CanUserAccessDeviceAsync(userId, deviceId, cancellationToken);
|
||||||
return Ok(new { hasAccess });
|
return Ok(new { hasAccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ایجاد پست اسنپشات وضعیت سنسورها و رلهها
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("sensor-snapshot")]
|
||||||
|
public async Task<ActionResult<int>> CreateSensorSnapshot(
|
||||||
|
CreateSensorSnapshotRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await _postService.CreateSensorSnapshotAsync(request, cancellationToken);
|
||||||
|
return Ok(new { id, message = "وضعیت سنسورها و رلهها ارسال شد" });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// آپلود فایل صوتی (ویس) برای پست
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{postId}/voice")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<ActionResult> UploadVoice(
|
||||||
|
int postId,
|
||||||
|
[FromForm] IFormFile file,
|
||||||
|
[FromForm] int? durationSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return BadRequest(new { error = "فایل انتخاب نشده است" });
|
||||||
|
|
||||||
|
var allowedTypes = new[]
|
||||||
|
{
|
||||||
|
"audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg",
|
||||||
|
"audio/wav", "audio/x-wav", "audio/amr", "audio/aac", "audio/m4a"
|
||||||
|
};
|
||||||
|
if (!allowedTypes.Contains(file.ContentType.ToLower()))
|
||||||
|
return BadRequest(new { error = "فرمت فایل صوتی مجاز نیست" });
|
||||||
|
|
||||||
|
if (file.Length > 10 * 1024 * 1024)
|
||||||
|
return BadRequest(new { error = "حجم فایل صوتی نباید بیشتر از 10 مگابایت باشد" });
|
||||||
|
|
||||||
|
var uploadsFolder = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "voice");
|
||||||
|
Directory.CreateDirectory(uploadsFolder);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||||
|
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||||
|
|
||||||
|
await using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = $"/uploads/voice/{fileName}";
|
||||||
|
await _postService.SetPostVoiceAsync(
|
||||||
|
postId,
|
||||||
|
file.FileName,
|
||||||
|
relativePath,
|
||||||
|
file.ContentType,
|
||||||
|
file.Length,
|
||||||
|
durationSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { filePath = relativePath, message = "ویس با موفقیت آپلود شد" });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error uploading voice for post {PostId}", postId);
|
||||||
|
return StatusCode(500, new { error = "خطا در آپلود ویس" });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
26
src/GreenHome.Application/Dtos/Device/DeviceMemberDto.cs
Normal file
26
src/GreenHome.Application/Dtos/Device/DeviceMemberDto.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
public sealed class DeviceMemberDto
|
||||||
|
{
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public DeviceMemberRole Role { get; set; } = DeviceMemberRole.Worker;
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
|
public bool ReceiveAlerts { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertDeviceMemberRequest
|
||||||
|
{
|
||||||
|
public required int DeviceId { get; set; }
|
||||||
|
public required string Mobile { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public DeviceMemberRole Role { get; set; } = DeviceMemberRole.Worker;
|
||||||
|
public bool ReceiveAlerts { get; set; } = true;
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
|
}
|
||||||
@@ -7,7 +7,12 @@ public sealed class DevicePostDto
|
|||||||
public int AuthorUserId { get; set; }
|
public int AuthorUserId { get; set; }
|
||||||
public string AuthorName { get; set; } = string.Empty;
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
public string AuthorFamily { get; set; } = string.Empty;
|
public string AuthorFamily { get; set; } = string.Empty;
|
||||||
|
public string? AuthorAvatar { get; set; }
|
||||||
|
public Domain.DeviceMemberRole? AuthorRole { get; set; }
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public Domain.DevicePostType PostType { get; set; } = Domain.DevicePostType.Text;
|
||||||
|
public string? VoiceFilePath { get; set; }
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
public List<DevicePostImageDto> Images { get; set; } = new();
|
public List<DevicePostImageDto> Images { get; set; } = new();
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
@@ -43,3 +48,9 @@ public sealed class DevicePostFilter
|
|||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 1;
|
||||||
public int PageSize { get; set; } = 20;
|
public int PageSize { get; set; } = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class CreateSensorSnapshotRequest
|
||||||
|
{
|
||||||
|
public required int DeviceId { get; set; }
|
||||||
|
public required int AuthorUserId { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
public interface IDeviceMemberService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<DeviceMemberDto>> ListByDeviceAsync(int deviceId, CancellationToken cancellationToken);
|
||||||
|
Task<DeviceMemberDto> UpsertAsync(UpsertDeviceMemberRequest request, CancellationToken cancellationToken);
|
||||||
|
Task DeleteAsync(int deviceId, int userId, CancellationToken cancellationToken);
|
||||||
|
Task<bool> CanManageDeviceAsync(int actorUserId, int deviceId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -10,4 +10,6 @@ public interface IDevicePostService
|
|||||||
Task<int> AddImageToPostAsync(int postId, string fileName, string filePath, string contentType, long fileSize, CancellationToken cancellationToken);
|
Task<int> AddImageToPostAsync(int postId, string fileName, string filePath, string contentType, long fileSize, CancellationToken cancellationToken);
|
||||||
Task DeleteImageAsync(int imageId, CancellationToken cancellationToken);
|
Task DeleteImageAsync(int imageId, CancellationToken cancellationToken);
|
||||||
Task<bool> CanUserAccessDeviceAsync(int userId, int deviceId, CancellationToken cancellationToken);
|
Task<bool> CanUserAccessDeviceAsync(int userId, int deviceId, CancellationToken cancellationToken);
|
||||||
|
Task<int> CreateSensorSnapshotAsync(CreateSensorSnapshotRequest request, CancellationToken cancellationToken);
|
||||||
|
Task SetPostVoiceAsync(int postId, string fileName, string filePath, string contentType, long fileSize, int? durationSeconds, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,10 @@ public sealed class MappingProfile : Profile
|
|||||||
.ForMember(dest => dest.AuthorFamily, opt => opt.MapFrom(src => src.AuthorUser.Family));
|
.ForMember(dest => dest.AuthorFamily, opt => opt.MapFrom(src => src.AuthorUser.Family));
|
||||||
|
|
||||||
CreateMap<Domain.DevicePostImage, DevicePostImageDto>();
|
CreateMap<Domain.DevicePostImage, DevicePostImageDto>();
|
||||||
|
|
||||||
|
CreateMap<Domain.DeviceUser, DeviceMemberDto>()
|
||||||
|
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name))
|
||||||
|
.ForMember(dest => dest.Family, opt => opt.MapFrom(src => src.Family))
|
||||||
|
.ForMember(dest => dest.Mobile, opt => opt.MapFrom(src => src.User.Mobile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/GreenHome.Domain/DeviceMemberRole.cs
Normal file
22
src/GreenHome.Domain/DeviceMemberRole.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نقش یک عضو در تیم گلخانه (دستگاه)
|
||||||
|
/// </summary>
|
||||||
|
public enum DeviceMemberRole
|
||||||
|
{
|
||||||
|
/// <summary>مدیر</summary>
|
||||||
|
Manager = 0,
|
||||||
|
|
||||||
|
/// <summary>ناظر</summary>
|
||||||
|
Supervisor = 1,
|
||||||
|
|
||||||
|
/// <summary>مهندس آب و خاک</summary>
|
||||||
|
WaterSoilEngineer = 2,
|
||||||
|
|
||||||
|
/// <summary>تکنسین</summary>
|
||||||
|
Technician = 3,
|
||||||
|
|
||||||
|
/// <summary>کارگر</summary>
|
||||||
|
Worker = 4
|
||||||
|
}
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
namespace GreenHome.Domain;
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع پیام در گروه مجازی دستگاه
|
||||||
|
/// </summary>
|
||||||
|
public enum DevicePostType
|
||||||
|
{
|
||||||
|
/// <summary>متن</summary>
|
||||||
|
Text = 0,
|
||||||
|
|
||||||
|
/// <summary>تصویر</summary>
|
||||||
|
Image = 1,
|
||||||
|
|
||||||
|
/// <summary>ویس</summary>
|
||||||
|
Voice = 2,
|
||||||
|
|
||||||
|
/// <summary>اسنپشات وضعیت سنسورها و رلهها</summary>
|
||||||
|
SensorSnapshot = 3
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// پستهای گروه مجازی دستگاه (تایملاین مشترک)
|
/// پستهای گروه مجازی دستگاه (تایملاین مشترک)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -24,6 +42,21 @@ public sealed class DevicePost
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع پیام
|
||||||
|
/// </summary>
|
||||||
|
public DevicePostType PostType { get; set; } = DevicePostType.Text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مسیر نسبی فایل صوتی (برای پیام ویس)
|
||||||
|
/// </summary>
|
||||||
|
public string? VoiceFilePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مدتزمان ویس (ثانیه)
|
||||||
|
/// </summary>
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// تصاویر پیوست
|
/// تصاویر پیوست
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ public sealed class DeviceSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int RelayCount { get; set; } = 4;
|
public int RelayCount { get; set; } = 4;
|
||||||
|
|
||||||
|
// ponytail: legacy field kept only to avoid a destructive column drop.
|
||||||
|
// Recipients now come from the team (DeviceUsers.ReceiveAlerts) - see AlertService.BuildRecipients.
|
||||||
[StringLength(120)]
|
[StringLength(120)]
|
||||||
public string SmsRecievers { get; set; } = string.Empty;
|
public string SmsRecievers { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,25 @@ public sealed class DeviceUser
|
|||||||
/// آیا این کاربر باید هشدارهای این دستگاه را دریافت کند؟
|
/// آیا این کاربر باید هشدارهای این دستگاه را دریافت کند؟
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ReceiveAlerts { get; set; } = true;
|
public bool ReceiveAlerts { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نقش عضو در تیم گلخانه (مدیر، ناظر، مهندس آب و خاک، تکنسین، ...)
|
||||||
|
/// </summary>
|
||||||
|
public DeviceMemberRole Role { get; set; } = DeviceMemberRole.Worker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام عضو در همین گلخانه (میتواند در هر گلخانه متفاوت باشد)
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام خانوادگی عضو در همین گلخانه
|
||||||
|
/// </summary>
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مسیر نسبی آواتار عضو (ذخیرهشده در uploads/avatars)
|
||||||
|
/// </summary>
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
src/GreenHome.Domain/MobileNormalizer.cs
Normal file
30
src/GreenHome.Domain/MobileNormalizer.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نرمالسازی یکپارچه شماره موبایل به فرمت «۰۹xxxxxxxxx».
|
||||||
|
/// تنها منبع یکسانسازی و اعتبارسنجی شماره موبایل در کل سیستم.
|
||||||
|
/// </summary>
|
||||||
|
public static class MobileNormalizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// شماره را به فرمت یکپارچه «۰۹xxxxxxxxx» برمیگرداند.
|
||||||
|
/// اگر شماره معتبر نباشد، رشته خالی برمیگرداند.
|
||||||
|
/// </summary>
|
||||||
|
public static string Normalize(string? mobile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(mobile))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var digits = new string(mobile.Where(char.IsDigit).ToArray());
|
||||||
|
|
||||||
|
digits = digits.Length switch
|
||||||
|
{
|
||||||
|
10 when digits[0] == '9' => "0" + digits,
|
||||||
|
12 when digits.StartsWith("98") => "0" + digits.Substring(2),
|
||||||
|
14 when digits.StartsWith("0098") => "0" + digits.Substring(4),
|
||||||
|
_ => digits
|
||||||
|
};
|
||||||
|
|
||||||
|
return digits.Length == 11 && digits.StartsWith("09") ? digits : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,10 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.ToTable("DeviceUsers");
|
b.ToTable("DeviceUsers");
|
||||||
b.HasKey(x => new { x.DeviceId, x.UserId });
|
b.HasKey(x => new { x.DeviceId, x.UserId });
|
||||||
b.Property(x => x.ReceiveAlerts).IsRequired().HasDefaultValue(true);
|
b.Property(x => x.ReceiveAlerts).IsRequired().HasDefaultValue(true);
|
||||||
|
b.Property(x => x.Role).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.Name).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.Family).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.AvatarPath).HasMaxLength(500);
|
||||||
b.HasOne(x => x.Device)
|
b.HasOne(x => x.Device)
|
||||||
.WithMany(d => d.DeviceUsers)
|
.WithMany(d => d.DeviceUsers)
|
||||||
.HasForeignKey(x => x.DeviceId)
|
.HasForeignKey(x => x.DeviceId)
|
||||||
@@ -383,6 +387,8 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.ToTable("DevicePosts");
|
b.ToTable("DevicePosts");
|
||||||
b.HasKey(x => x.Id);
|
b.HasKey(x => x.Id);
|
||||||
b.Property(x => x.Content).IsRequired().HasMaxLength(5000);
|
b.Property(x => x.Content).IsRequired().HasMaxLength(5000);
|
||||||
|
b.Property(x => x.PostType).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.VoiceFilePath).HasMaxLength(500);
|
||||||
b.HasOne(x => x.Device)
|
b.HasOne(x => x.Device)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.DeviceId)
|
.HasForeignKey(x => x.DeviceId)
|
||||||
|
|||||||
1441
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260910115241_AddTeamAndPostTypes.Designer.cs
generated
Normal file
1441
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260910115241_AddTeamAndPostTypes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTeamAndPostTypes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AvatarPath",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Role",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 4);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PostType",
|
||||||
|
table: "DevicePosts",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "VoiceDurationSeconds",
|
||||||
|
table: "DevicePosts",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "VoiceFilePath",
|
||||||
|
table: "DevicePosts",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AvatarPath",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Role",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PostType",
|
||||||
|
table: "DevicePosts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VoiceDurationSeconds",
|
||||||
|
table: "DevicePosts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VoiceFilePath",
|
||||||
|
table: "DevicePosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MoveMemberNamesToDeviceUser : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Family",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "nvarchar(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "nvarchar(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
// بکفیل نام/نامخانوادگی اعضای موجود از جدول Users
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE du SET du.Name = COALESCE(NULLIF(u.Name, ''), u.Mobile), du.Family = u.Family " +
|
||||||
|
"FROM DeviceUsers du INNER JOIN Users u ON u.Id = du.UserId;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Family",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Name",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -517,9 +517,19 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("DeviceId")
|
b.Property<int>("DeviceId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("PostType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int?>("VoiceDurationSeconds")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("VoiceFilePath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("AuthorUserId");
|
b.HasIndex("AuthorUserId");
|
||||||
@@ -708,11 +718,28 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("UserId")
|
b.Property<int>("UserId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Family")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<bool>("ReceiveAlerts")
|
b.Property<bool>("ReceiveAlerts")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
.HasDefaultValue(true);
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.HasKey("DeviceId", "UserId");
|
b.HasKey("DeviceId", "UserId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IDeviceSettingsService, DeviceSettingsService>();
|
services.AddScoped<IDeviceSettingsService, DeviceSettingsService>();
|
||||||
services.AddScoped<IDeviceTokenService, DeviceTokenService>();
|
services.AddScoped<IDeviceTokenService, DeviceTokenService>();
|
||||||
services.AddScoped<IDevicePostService, DevicePostService>();
|
services.AddScoped<IDevicePostService, DevicePostService>();
|
||||||
|
services.AddScoped<IDeviceMemberService, DeviceMemberService>();
|
||||||
|
|
||||||
// Telemetry
|
// Telemetry
|
||||||
services.AddScoped<ITelemetryService, TelemetryService>();
|
services.AddScoped<ITelemetryService, TelemetryService>();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public sealed class AlertService : IAlertService
|
|||||||
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
|
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
|
||||||
|
|
||||||
// 3. Build recipient list
|
// 3. Build recipient list
|
||||||
var recipients = BuildRecipients(device, settings);
|
var recipients = BuildRecipients(device);
|
||||||
if (recipients.Length == 0)
|
if (recipients.Length == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No recipients for device: DeviceId={DeviceId}", deviceId);
|
_logger.LogInformation("No recipients for device: DeviceId={DeviceId}", deviceId);
|
||||||
@@ -220,11 +220,13 @@ public sealed class AlertService : IAlertService
|
|||||||
usersToAlert.Count, deviceId);
|
usersToAlert.Count, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildRecipients(DomainDevice device, DeviceSettingsDto? settings)
|
private static string BuildRecipients(DomainDevice device)
|
||||||
{
|
{
|
||||||
|
// Recipients come from the team: the device owner plus members whose
|
||||||
|
// "receive alerts" checkbox is ticked in the team section.
|
||||||
return string.Join(",",
|
return string.Join(",",
|
||||||
(device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
|
new[] { device.User.Mobile }
|
||||||
.Split(',')
|
.Concat(device.DeviceUsers.Select(du => du.User.Mobile))
|
||||||
.Select(x => x.Trim())
|
.Select(x => x.Trim())
|
||||||
.Where(x => x.Length > 0)
|
.Where(x => x.Length > 0)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ public sealed class AuthService : IAuthService
|
|||||||
SendCodeRequest request,
|
SendCodeRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var mobile = NormalizeMobile(request.Mobile);
|
var mobile = Domain.MobileNormalizer.Normalize(request.Mobile);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(mobile) || mobile.Length != 11 || !mobile.StartsWith("09"))
|
if (mobile.Length == 0)
|
||||||
{
|
{
|
||||||
return new SendCodeResponse
|
return new SendCodeResponse
|
||||||
{
|
{
|
||||||
@@ -117,7 +117,7 @@ public sealed class AuthService : IAuthService
|
|||||||
VerifyCodeRequest request,
|
VerifyCodeRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var mobile = NormalizeMobile(request.Mobile);
|
var mobile = Domain.MobileNormalizer.Normalize(request.Mobile);
|
||||||
var code = request.Code.Trim();
|
var code = request.Code.Trim();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(mobile) || string.IsNullOrWhiteSpace(code) || code.Length != 4)
|
if (string.IsNullOrWhiteSpace(mobile) || string.IsNullOrWhiteSpace(code) || code.Length != 4)
|
||||||
@@ -186,7 +186,7 @@ public sealed class AuthService : IAuthService
|
|||||||
|
|
||||||
public async Task<bool> CanResendCodeAsync(string mobile, CancellationToken cancellationToken)
|
public async Task<bool> CanResendCodeAsync(string mobile, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedMobile = NormalizeMobile(mobile);
|
var normalizedMobile = Domain.MobileNormalizer.Normalize(mobile);
|
||||||
var lastCode = await _dbContext.VerificationCodes
|
var lastCode = await _dbContext.VerificationCodes
|
||||||
.Where(v => v.Mobile == normalizedMobile && !v.IsUsed)
|
.Where(v => v.Mobile == normalizedMobile && !v.IsUsed)
|
||||||
.OrderByDescending(v => v.CreatedAt)
|
.OrderByDescending(v => v.CreatedAt)
|
||||||
@@ -252,20 +252,4 @@ public sealed class AuthService : IAuthService
|
|||||||
return $"{payload}.{signature}";
|
return $"{payload}.{signature}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeMobile(string mobile)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(mobile))
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
var normalized = new string(mobile.Where(char.IsDigit).ToArray());
|
|
||||||
|
|
||||||
if (normalized.StartsWith("9") && normalized.Length == 10)
|
|
||||||
normalized = "0" + normalized;
|
|
||||||
else if (normalized.StartsWith("0098") && normalized.Length == 13)
|
|
||||||
normalized = "0" + normalized.Substring(3);
|
|
||||||
else if (normalized.StartsWith("98") && normalized.Length == 12)
|
|
||||||
normalized = "0" + normalized.Substring(2);
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using GreenHome.Application;
|
||||||
|
using GreenHome.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Services.Device;
|
||||||
|
|
||||||
|
public sealed class DeviceMemberService : IDeviceMemberService
|
||||||
|
{
|
||||||
|
private readonly GreenHomeDbContext _dbContext;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
|
public DeviceMemberService(GreenHomeDbContext dbContext, IMapper mapper)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DeviceMemberDto>> ListByDeviceAsync(
|
||||||
|
int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var members = await _dbContext.DeviceUsers
|
||||||
|
.Include(du => du.User)
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(du => du.DeviceId == deviceId)
|
||||||
|
.OrderBy(du => du.Role)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return _mapper.Map<IReadOnlyList<DeviceMemberDto>>(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeviceMemberDto> UpsertAsync(
|
||||||
|
UpsertDeviceMemberRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var mobile = Domain.MobileNormalizer.Normalize(request.Mobile);
|
||||||
|
|
||||||
|
if (mobile.Length == 0)
|
||||||
|
throw new InvalidOperationException("شماره موبایل معتبر نیست");
|
||||||
|
|
||||||
|
var name = string.IsNullOrWhiteSpace(request.Name) ? mobile : request.Name.Trim();
|
||||||
|
var family = request.Family?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
user = new Domain.User
|
||||||
|
{
|
||||||
|
Mobile = mobile,
|
||||||
|
Name = string.Empty,
|
||||||
|
Family = string.Empty,
|
||||||
|
Role = Domain.UserRole.Normal
|
||||||
|
};
|
||||||
|
_dbContext.Users.Add(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await _dbContext.DeviceUsers
|
||||||
|
.FirstOrDefaultAsync(du => du.DeviceId == request.DeviceId && du.UserId == user.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (member == null)
|
||||||
|
{
|
||||||
|
member = new Domain.DeviceUser
|
||||||
|
{
|
||||||
|
DeviceId = request.DeviceId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = request.Role,
|
||||||
|
Name = name,
|
||||||
|
Family = family,
|
||||||
|
AvatarPath = request.AvatarPath,
|
||||||
|
ReceiveAlerts = request.ReceiveAlerts
|
||||||
|
};
|
||||||
|
_dbContext.DeviceUsers.Add(member);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
member.Role = request.Role;
|
||||||
|
member.Name = name;
|
||||||
|
member.Family = family;
|
||||||
|
member.AvatarPath = request.AvatarPath ?? member.AvatarPath;
|
||||||
|
member.ReceiveAlerts = request.ReceiveAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// مسابقه: کاربر یا عضویت همزمان توسط درخواست دیگری ثبت شده؛ از ردیف موجود استفاده میکنیم.
|
||||||
|
_dbContext.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
user = await _dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
throw;
|
||||||
|
|
||||||
|
member = await _dbContext.DeviceUsers
|
||||||
|
.FirstOrDefaultAsync(du => du.DeviceId == request.DeviceId && du.UserId == user.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (member == null)
|
||||||
|
{
|
||||||
|
member = new Domain.DeviceUser
|
||||||
|
{
|
||||||
|
DeviceId = request.DeviceId,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = request.Role,
|
||||||
|
Name = name,
|
||||||
|
Family = family,
|
||||||
|
AvatarPath = request.AvatarPath,
|
||||||
|
ReceiveAlerts = request.ReceiveAlerts
|
||||||
|
};
|
||||||
|
_dbContext.DeviceUsers.Add(member);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetMemberDtoAsync(request.DeviceId, user.Id, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("عضو پس از ثبت یافت نشد");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int deviceId, int userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var member = await _dbContext.DeviceUsers
|
||||||
|
.FirstOrDefaultAsync(du => du.DeviceId == deviceId && du.UserId == userId, cancellationToken);
|
||||||
|
|
||||||
|
if (member == null)
|
||||||
|
throw new InvalidOperationException("عضو مورد نظر یافت نشد");
|
||||||
|
|
||||||
|
_dbContext.DeviceUsers.Remove(member);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanManageDeviceAsync(
|
||||||
|
int actorUserId,
|
||||||
|
int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var device = await _dbContext.Devices
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(d => d.Id == deviceId)
|
||||||
|
.Select(d => d.UserId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (device == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (device == actorUserId)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var actorRole = await _dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.Id == actorUserId)
|
||||||
|
.Select(u => (Domain.UserRole?)u.Role)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (actorRole == Domain.UserRole.Admin)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var memberRole = await _dbContext.DeviceUsers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(du => du.DeviceId == deviceId && du.UserId == actorUserId)
|
||||||
|
.Select(du => (Domain.DeviceMemberRole?)du.Role)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return memberRole == Domain.DeviceMemberRole.Manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DeviceMemberDto?> GetMemberDtoAsync(
|
||||||
|
int deviceId,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var member = await _dbContext.DeviceUsers
|
||||||
|
.Include(du => du.User)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(du => du.DeviceId == deviceId && du.UserId == userId, cancellationToken);
|
||||||
|
|
||||||
|
return member == null ? null : _mapper.Map<DeviceMemberDto>(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@ public sealed class DevicePostService : IDevicePostService
|
|||||||
.Take(filter.PageSize)
|
.Take(filter.PageSize)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<DevicePostDto>>(posts);
|
var members = await GetMembersMapAsync(filter.DeviceId, cancellationToken);
|
||||||
|
var dtos = posts.Select(p => ToDto(p, members)).ToList();
|
||||||
|
|
||||||
return new PagedResult<DevicePostDto>
|
return new PagedResult<DevicePostDto>
|
||||||
{
|
{
|
||||||
@@ -56,7 +57,11 @@ public sealed class DevicePostService : IDevicePostService
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||||
|
|
||||||
return post != null ? _mapper.Map<DevicePostDto>(post) : null;
|
if (post == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var members = await GetMembersMapAsync(post.DeviceId, cancellationToken);
|
||||||
|
return ToDto(post, members);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CreatePostAsync(
|
public async Task<int> CreatePostAsync(
|
||||||
@@ -135,6 +140,10 @@ public sealed class DevicePostService : IDevicePostService
|
|||||||
};
|
};
|
||||||
|
|
||||||
_dbContext.DevicePostImages.Add(image);
|
_dbContext.DevicePostImages.Add(image);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(post.Content))
|
||||||
|
post.PostType = Domain.DevicePostType.Image;
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return image.Id;
|
return image.Id;
|
||||||
@@ -157,6 +166,15 @@ public sealed class DevicePostService : IDevicePostService
|
|||||||
int deviceId,
|
int deviceId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var role = await _dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.Id == userId)
|
||||||
|
.Select(u => (Domain.UserRole?)u.Role)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (role == Domain.UserRole.Admin)
|
||||||
|
return true;
|
||||||
|
|
||||||
var hasAccess = await _dbContext.Devices
|
var hasAccess = await _dbContext.Devices
|
||||||
.AnyAsync(d => d.Id == deviceId && d.UserId == userId, cancellationToken);
|
.AnyAsync(d => d.Id == deviceId && d.UserId == userId, cancellationToken);
|
||||||
|
|
||||||
@@ -168,4 +186,137 @@ public sealed class DevicePostService : IDevicePostService
|
|||||||
|
|
||||||
return hasAccess;
|
return hasAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateSensorSnapshotAsync(
|
||||||
|
CreateSensorSnapshotRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var hasAccess = await CanUserAccessDeviceAsync(request.AuthorUserId, request.DeviceId, cancellationToken);
|
||||||
|
if (!hasAccess)
|
||||||
|
throw new UnauthorizedAccessException("کاربر به این دستگاه دسترسی ندارد");
|
||||||
|
|
||||||
|
var telemetry = await _dbContext.TelemetryRecords
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.DeviceId == request.DeviceId)
|
||||||
|
.OrderByDescending(t => t.ServerTimestampUtc)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var relays = await _dbContext.DeviceRelays
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.DeviceSettings.DeviceId == request.DeviceId)
|
||||||
|
.OrderBy(r => r.RelayIndex)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var post = new Domain.DevicePost
|
||||||
|
{
|
||||||
|
DeviceId = request.DeviceId,
|
||||||
|
AuthorUserId = request.AuthorUserId,
|
||||||
|
PostType = Domain.DevicePostType.SensorSnapshot,
|
||||||
|
Content = BuildSnapshotContent(telemetry, relays),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_dbContext.DevicePosts.Add(post);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return post.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetPostVoiceAsync(
|
||||||
|
int postId,
|
||||||
|
string fileName,
|
||||||
|
string filePath,
|
||||||
|
string contentType,
|
||||||
|
long fileSize,
|
||||||
|
int? durationSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _dbContext.DevicePosts
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == postId, cancellationToken);
|
||||||
|
|
||||||
|
if (post == null)
|
||||||
|
throw new InvalidOperationException($"پست با شناسه {postId} یافت نشد");
|
||||||
|
|
||||||
|
post.VoiceFilePath = filePath;
|
||||||
|
post.VoiceDurationSeconds = durationSeconds;
|
||||||
|
post.PostType = Domain.DevicePostType.Voice;
|
||||||
|
post.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<int, Domain.DeviceUser>> GetMembersMapAsync(
|
||||||
|
int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _dbContext.DeviceUsers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(du => du.DeviceId == deviceId)
|
||||||
|
.ToDictionaryAsync(du => du.UserId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DevicePostDto ToDto(Domain.DevicePost post, IReadOnlyDictionary<int, Domain.DeviceUser> members)
|
||||||
|
{
|
||||||
|
var dto = _mapper.Map<DevicePostDto>(post);
|
||||||
|
|
||||||
|
if (members.TryGetValue(post.AuthorUserId, out var member))
|
||||||
|
{
|
||||||
|
dto.AuthorAvatar = member.AvatarPath;
|
||||||
|
dto.AuthorRole = member.Role;
|
||||||
|
dto.AuthorName = member.Name;
|
||||||
|
dto.AuthorFamily = member.Family;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSnapshotContent(
|
||||||
|
Domain.TelemetryRecord? telemetry,
|
||||||
|
IReadOnlyList<Domain.DeviceRelay> relays)
|
||||||
|
{
|
||||||
|
if (telemetry == null)
|
||||||
|
return "📊 وضعیت فعلی: دادهای ثبت نشده است.";
|
||||||
|
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"📊 وضعیت فعلی سنسورها:",
|
||||||
|
$"🌡 دما: {telemetry.TemperatureC} °C",
|
||||||
|
$"💧 رطوبت هوا: {telemetry.HumidityPercent}٪",
|
||||||
|
$"🌱 رطوبت خاک: {telemetry.SoilPercent}٪",
|
||||||
|
$"☁️ گاز: {telemetry.GasPPM} ppm",
|
||||||
|
$"💡 نور: {telemetry.Lux} lux"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (telemetry.Voltage.HasValue)
|
||||||
|
lines.Add($"🔌 ولتاژ: {telemetry.Voltage} V");
|
||||||
|
if (telemetry.BatteryPercent.HasValue)
|
||||||
|
lines.Add($"🔋 باتری: {telemetry.BatteryPercent}٪");
|
||||||
|
if (telemetry.Power.HasValue)
|
||||||
|
lines.Add(telemetry.Power.Value == 1 ? "⚡ برق: وصل" : "⚡ برق: قطع");
|
||||||
|
|
||||||
|
if (relays.Count > 0)
|
||||||
|
{
|
||||||
|
lines.Add("🔀 رلهها:");
|
||||||
|
foreach (var relay in relays)
|
||||||
|
{
|
||||||
|
var state = ParseRelayState(telemetry.RelayStates, relay.RelayIndex) ? "روشن" : "خاموش";
|
||||||
|
var name = string.IsNullOrWhiteSpace(relay.Name) ? $"رله {relay.RelayIndex}" : relay.Name;
|
||||||
|
lines.Add($"• {name}: {state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('\n', lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ParseRelayState(string? relayStates, int relayIndex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relayStates))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var parts = relayStates.Split(',');
|
||||||
|
if (relayIndex <= 0 || relayIndex > parts.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return parts[relayIndex - 1] == "1";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,8 @@ public sealed class DeviceService : IDeviceService
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(d => d.User);
|
.Include(d => d.User);
|
||||||
|
|
||||||
if (user.Role == Domain.UserRole.Normal)
|
if (user.Role == Domain.UserRole.Normal || user.Role == Domain.UserRole.Supervisor)
|
||||||
query = query.Where(d => d.UserId == user.Id);
|
query = query.Where(d => d.UserId == user.Id || d.DeviceUsers.Any(du => du.UserId == user.Id));
|
||||||
else if (user.Role == Domain.UserRole.Supervisor)
|
|
||||||
query = query.Where(d => d.DeviceUsers.Any(du => du.UserId == user.Id));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Search))
|
if (!string.IsNullOrWhiteSpace(filter.Search))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
|||||||
var entity = await _dbContext.DeviceSettings
|
var entity = await _dbContext.DeviceSettings
|
||||||
.Include(x => x.Relays)
|
.Include(x => x.Relays)
|
||||||
.ThenInclude(r => r.Rules)
|
.ThenInclude(r => r.Rules)
|
||||||
|
.Include(x => x.Device)
|
||||||
.FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken);
|
||||||
|
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
@@ -59,6 +60,9 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
|||||||
_mapper.Map(dto, entity);
|
_mapper.Map(dto, entity);
|
||||||
entity.UpdatedAt = DateTime.UtcNow;
|
entity.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(dto.DeviceName))
|
||||||
|
entity.Device.DeviceName = dto.DeviceName.Trim();
|
||||||
|
|
||||||
SyncRelays(entity, dto.Relays ?? new List<DeviceRelayDto>());
|
SyncRelays(entity, dto.Relays ?? new List<DeviceRelayDto>());
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import DayDetailsPage from '@/app/day-details/page'
|
|||||||
import WeatherPage from '@/app/weather/page'
|
import WeatherPage from '@/app/weather/page'
|
||||||
import ProfilePage from '@/app/profile/page'
|
import ProfilePage from '@/app/profile/page'
|
||||||
import DevicePage from '@/app/device/page'
|
import DevicePage from '@/app/device/page'
|
||||||
|
import GroupPage from '@/app/group/page'
|
||||||
|
|
||||||
function DeviceRedirect() {
|
function DeviceRedirect() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
<Route path="/weather" element={<WeatherPage />} />
|
<Route path="/weather" element={<WeatherPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/device" element={<DevicePage />} />
|
<Route path="/device" element={<DevicePage />} />
|
||||||
|
<Route path="/group" element={<GroupPage />} />
|
||||||
<Route path="/device-settings" element={<DeviceRedirect />} />
|
<Route path="/device-settings" element={<DeviceRedirect />} />
|
||||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useState, Suspense } from 'react'
|
import { useState, Suspense } from 'react'
|
||||||
import { useSearchParams } from '@/lib/router'
|
import { useSearchParams } from '@/lib/router'
|
||||||
import { MonitorCog, Plug, Bell, Info } from 'lucide-react'
|
import { MonitorCog, Plug, Bell, Info, Users } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { AppShell } from '@/components/layout'
|
import { AppShell } from '@/components/layout'
|
||||||
import { RelaySettingsTab } from '@/components/device-settings/RelaySettingsTab'
|
import { RelaySettingsTab } from '@/components/device-settings/RelaySettingsTab'
|
||||||
import { DeviceInfoTab } from '@/components/device-settings/DeviceInfoTab'
|
import { DeviceInfoTab } from '@/components/device-settings/DeviceInfoTab'
|
||||||
import { AlertSettingsTab } from '@/components/alert-settings/AlertSettingsTab'
|
import { AlertSettingsTab } from '@/components/alert-settings/AlertSettingsTab'
|
||||||
|
import { TeamPanel } from '@/components/team/TeamPanel'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type DeviceTab = 'relays' | 'alerts' | 'settings'
|
type DeviceTab = 'relays' | 'alerts' | 'settings' | 'team'
|
||||||
|
|
||||||
const tabs: { key: DeviceTab; label: string; icon: typeof Plug }[] = [
|
const tabs: { key: DeviceTab; label: string; icon: typeof Plug }[] = [
|
||||||
{ key: 'settings', label: 'تنظیمات', icon: Info },
|
{ key: 'settings', label: 'تنظیمات', icon: Info },
|
||||||
{ key: 'relays', label: 'رلهها', icon: Plug },
|
{ key: 'relays', label: 'رلهها', icon: Plug },
|
||||||
{ key: 'alerts', label: 'هشدارها', icon: Bell },
|
{ key: 'alerts', label: 'هشدارها', icon: Bell },
|
||||||
|
{ key: 'team', label: 'تیم', icon: Users },
|
||||||
]
|
]
|
||||||
|
|
||||||
function DevicePageContent() {
|
function DevicePageContent() {
|
||||||
@@ -57,6 +59,7 @@ function DevicePageContent() {
|
|||||||
{tab === 'relays' && <RelaySettingsTab deviceId={deviceId} />}
|
{tab === 'relays' && <RelaySettingsTab deviceId={deviceId} />}
|
||||||
{tab === 'alerts' && <AlertSettingsTab deviceId={deviceId} />}
|
{tab === 'alerts' && <AlertSettingsTab deviceId={deviceId} />}
|
||||||
{tab === 'settings' && <DeviceInfoTab deviceId={deviceId} deviceName={deviceName} />}
|
{tab === 'settings' && <DeviceInfoTab deviceId={deviceId} deviceName={deviceName} />}
|
||||||
|
{tab === 'team' && <TeamPanel deviceId={deviceId} />}
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
|
|||||||
38
src/GreenHome.Web/src/app/group/page.tsx
Normal file
38
src/GreenHome.Web/src/app/group/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
import { useSearchParams } from '@/lib/router'
|
||||||
|
import { MessageCircle } from 'lucide-react'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
import { GroupFeed } from '@/components/group/GroupFeed'
|
||||||
|
|
||||||
|
function GroupPageContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const deviceId = Number(searchParams.get('deviceId') ?? '0')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
pageTitle="گفتگو"
|
||||||
|
pageIcon={MessageCircle}
|
||||||
|
iconGradient="from-green-500 to-emerald-600"
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{deviceId > 0 ? (
|
||||||
|
<GroupFeed deviceId={deviceId} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-12">
|
||||||
|
ابتدا یک دستگاه انتخاب کنید.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Loading message="در حال بارگذاری..." />}>
|
||||||
|
<GroupPageContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, type ReactNode } from 'react'
|
import { useEffect, useState, useCallback, type ReactNode } from 'react'
|
||||||
import { Save, X, MapPin, Sprout, Smartphone, BellRing, PhoneCall, Clock, type LucideIcon } from 'lucide-react'
|
import { Save, MapPin, Sprout, type LucideIcon } from 'lucide-react'
|
||||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { ErrorMessage, SuccessMessage, EmptyState, Button } from '@/components/common'
|
import { ErrorMessage, SuccessMessage, EmptyState, Button } from '@/components/common'
|
||||||
@@ -49,7 +49,6 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
const [smsInput, setSmsInput] = useState('')
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -72,29 +71,6 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
|||||||
setSettings(s => s ? { ...s, ...patch } : s)
|
setSettings(s => s ? { ...s, ...patch } : s)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addReceiver = () => {
|
|
||||||
if (!settings) return
|
|
||||||
const num = smsInput.replace(/[^\d]/g, '').trim()
|
|
||||||
if (!/^09\d{9}$/.test(num)) {
|
|
||||||
window.alert('شماره موبایل معتبر وارد کنید (مثلاً 09123456789)')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const receivers = settings.smsRecievers.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
if (receivers.includes(num)) {
|
|
||||||
window.alert('این شماره قبلاً اضافه شده است')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
update({ smsRecievers: [...receivers, num].join(',') })
|
|
||||||
setSmsInput('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeReceiver = (index: number) => {
|
|
||||||
if (!settings) return
|
|
||||||
const receivers = settings.smsRecievers.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
receivers.splice(index, 1)
|
|
||||||
update({ smsRecievers: receivers.join(',') })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
try {
|
try {
|
||||||
@@ -127,8 +103,6 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const receivers = settings.smsRecievers.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{error && <ErrorMessage message={error} />}
|
{error && <ErrorMessage message={error} />}
|
||||||
@@ -171,14 +145,29 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* نوع دستگاه */}
|
{/* مشخصات دستگاه */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={Sprout}
|
icon={Sprout}
|
||||||
iconClass="bg-indigo-100 text-indigo-600"
|
iconClass="bg-indigo-100 text-indigo-600"
|
||||||
title="نوع دستگاه"
|
title="مشخصات دستگاه"
|
||||||
subtitle="مشخصات محصول و ابعاد فضای کشت"
|
subtitle="نام گلخانه، نوع محصول و شماره سیمکارت دستگاه"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<SettingField label="نام گلخانه">
|
||||||
|
<input
|
||||||
|
className={FIELD_STYLES.indigo}
|
||||||
|
value={settings.deviceName || deviceName}
|
||||||
|
onChange={(e) => update({ deviceName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</SettingField>
|
||||||
|
<SettingField label="شماره تلفن دستگاه" hint="فقط خواندنی">
|
||||||
|
<input
|
||||||
|
dir="ltr"
|
||||||
|
readOnly
|
||||||
|
className={`${FIELD_STYLES.indigo} bg-gray-100 text-gray-500 cursor-not-allowed`}
|
||||||
|
value={settings.devicePhoneNumber}
|
||||||
|
/>
|
||||||
|
</SettingField>
|
||||||
<SettingField label="نوع محصول">
|
<SettingField label="نوع محصول">
|
||||||
<input className={FIELD_STYLES.indigo} value={settings.productType} onChange={(e) => update({ productType: e.target.value })} />
|
<input className={FIELD_STYLES.indigo} value={settings.productType} onChange={(e) => update({ productType: e.target.value })} />
|
||||||
</SettingField>
|
</SettingField>
|
||||||
@@ -197,95 +186,15 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* ارتباطات */}
|
|
||||||
<SectionCard
|
|
||||||
icon={Smartphone}
|
|
||||||
iconClass="bg-sky-100 text-sky-600"
|
|
||||||
title="ارتباطات"
|
|
||||||
subtitle="شماره سیمکارت دستگاه و فاصله ارسال داده"
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<SettingField label="شماره تلفن دستگاه">
|
|
||||||
<input
|
|
||||||
dir="ltr"
|
|
||||||
className={FIELD_STYLES.sky}
|
|
||||||
value={settings.devicePhoneNumber}
|
|
||||||
onChange={(e) => update({ devicePhoneNumber: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingField>
|
|
||||||
<SettingField
|
|
||||||
label="فاصله زمانی آپلود داده"
|
|
||||||
hint="دقیقه"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
dir="ltr"
|
|
||||||
className={FIELD_STYLES.sky}
|
|
||||||
value={settings.uploadIntervalMin}
|
|
||||||
onChange={(e) => update({ uploadIntervalMin: parseInt(e.target.value) || 0 })}
|
|
||||||
/>
|
|
||||||
</SettingField>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* اطلاعرسانی */}
|
{/* اطلاعرسانی */}
|
||||||
<SectionCard
|
{/* <SectionCard
|
||||||
icon={BellRing}
|
icon={BellRing}
|
||||||
iconClass="bg-amber-100 text-amber-600"
|
iconClass="bg-amber-100 text-amber-600"
|
||||||
title="اطلاعرسانی و هشدار"
|
title="اطلاعرسانی و هشدار"
|
||||||
subtitle="گیرندگان پیامک و محدودیت ارسال برای جلوگیری از مزاحمت"
|
subtitle="محدودیت ارسال برای جلوگیری از مزاحمت"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingField
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
label="گیرندگان پیامک"
|
|
||||||
hint="شماره موبایل دریافتکنندگان هشدار را اضافه کنید"
|
|
||||||
>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<input
|
|
||||||
className={cn(FIELD_STYLES.amber, 'pl-10')}
|
|
||||||
value={smsInput}
|
|
||||||
onChange={(e) => setSmsInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
addReceiver()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="09123456789"
|
|
||||||
inputMode="tel"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<PhoneCall className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-amber-400 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" type="button" onClick={addReceiver} className="shrink-0">
|
|
||||||
افزودن
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{receivers.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{receivers.map((num, i) => (
|
|
||||||
<span
|
|
||||||
key={num}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-amber-50 text-amber-700 text-xs font-medium border border-amber-200"
|
|
||||||
>
|
|
||||||
{num}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeReceiver(i)}
|
|
||||||
className="text-amber-500 hover:text-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-400 mt-1">هنوز شمارهای اضافه نشده است</p>
|
|
||||||
)}
|
|
||||||
</SettingField>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 border-t border-amber-100 pt-4">
|
|
||||||
<SettingField
|
<SettingField
|
||||||
label="فاصله ارسال پیامک"
|
label="فاصله ارسال پیامک"
|
||||||
hint="حداقل فاصله بین دو پیامک (دقیقه)"
|
hint="حداقل فاصله بین دو پیامک (دقیقه)"
|
||||||
@@ -318,7 +227,7 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
|||||||
</SettingField>
|
</SettingField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard> */}
|
||||||
|
|
||||||
<div className="flex justify-center pb-4">
|
<div className="flex justify-center pb-4">
|
||||||
<Button variant="success" size="lg" loading={saving} icon={Save} onClick={handleSave}>
|
<Button variant="success" size="lg" loading={saving} icon={Save} onClick={handleSave}>
|
||||||
|
|||||||
521
src/GreenHome.Web/src/components/group/GroupFeed.tsx
Normal file
521
src/GreenHome.Web/src/components/group/GroupFeed.tsx
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, DevicePostDto } from '@/lib/api'
|
||||||
|
import { resolveAssetUrl } from '@/lib/api/assets'
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Camera,
|
||||||
|
Mic,
|
||||||
|
Square,
|
||||||
|
Send,
|
||||||
|
Activity,
|
||||||
|
Smile,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button, Badge } from '@/components/common'
|
||||||
|
import { getDeviceMemberRoleLabel } from '@/features/team/constants'
|
||||||
|
import { formatPersianTime } from '@/lib/format/persian-date'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
import { gregorianToPersian } from '@/lib/date/persian-date'
|
||||||
|
import { usePolling } from '@/hooks/usePolling'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type GroupFeedProps = {
|
||||||
|
deviceId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJIS = ['😀', '😊', '😂', '👍', '🙏', '👋', '🔥', '✅', '❌', '🌱', '💧', '🌡️', '📊', '⚠️', '❤️', '🎉']
|
||||||
|
|
||||||
|
function getCurrentUserId(): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('user')
|
||||||
|
if (!raw) return 0
|
||||||
|
const user = JSON.parse(raw)
|
||||||
|
return typeof user?.id === 'number' ? user.id : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPostTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (isNaN(date.getTime())) return ''
|
||||||
|
|
||||||
|
const p = gregorianToPersian(date)
|
||||||
|
const dateStr = `${toPersianDigits(p.year)}/${toPersianDigits(String(p.month).padStart(2, '0'))}/${toPersianDigits(String(p.day).padStart(2, '0'))}`
|
||||||
|
return `${dateStr} - ${formatPersianTime(iso)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupFeed({ deviceId }: GroupFeedProps) {
|
||||||
|
const [posts, setPosts] = useState<DevicePostDto[]>([]) // newest first
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [showEmoji, setShowEmoji] = useState(false)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [recording, setRecording] = useState(false)
|
||||||
|
const [recordSeconds, setRecordSeconds] = useState(0)
|
||||||
|
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const postsRef = useRef<DevicePostDto[]>([])
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const chunksRef = useRef<Blob[]>([])
|
||||||
|
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const galleryInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const recordSecondsRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
postsRef.current = posts
|
||||||
|
}, [posts])
|
||||||
|
|
||||||
|
const loadPage = useCallback(async (targetPage: number) => {
|
||||||
|
const result = await api.getDevicePosts(deviceId, targetPage, 30)
|
||||||
|
return { items: result.items, total: result.totalCount }
|
||||||
|
}, [deviceId])
|
||||||
|
|
||||||
|
const loadInitial = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const { items, total } = await loadPage(1)
|
||||||
|
setPosts(items)
|
||||||
|
setPage(1)
|
||||||
|
setHasMore(items.length > 0 && total > items.length)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching posts:', err)
|
||||||
|
setError('خطا در دریافت پیامها')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [loadPage])
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
const { items, total } = await loadPage(1)
|
||||||
|
setPosts(items)
|
||||||
|
setPage(1)
|
||||||
|
setHasMore(items.length > 0 && total > items.length)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error refreshing posts:', err)
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [loadPage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial()
|
||||||
|
}, [loadInitial])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [posts.length])
|
||||||
|
|
||||||
|
usePolling<DevicePostDto>({
|
||||||
|
enabled: true,
|
||||||
|
intervalMs: 15000,
|
||||||
|
fetchNewData: async () => {
|
||||||
|
const result = await api.getDevicePosts(deviceId, 1, 30)
|
||||||
|
const existing = new Set(postsRef.current.map(p => p.id))
|
||||||
|
return result.items.filter(p => !existing.has(p.id))
|
||||||
|
},
|
||||||
|
onNewData: (newPosts) => {
|
||||||
|
if (newPosts.length === 0) return
|
||||||
|
setPosts(prev => [...newPosts, ...prev])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
const nextPage = page + 1
|
||||||
|
setLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const { items, total } = await loadPage(nextPage)
|
||||||
|
setPosts(prev => [...prev, ...items])
|
||||||
|
setPage(nextPage)
|
||||||
|
setHasMore(postsRef.current.length + items.length < total)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading more posts:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterPostCreated = useCallback(async () => {
|
||||||
|
await refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const sendText = async () => {
|
||||||
|
const content = text.trim()
|
||||||
|
if (!content) return
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
await api.createDevicePost(deviceId, userId, content)
|
||||||
|
setText('')
|
||||||
|
await afterPostCreated()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sending post:', err)
|
||||||
|
setError('خطا در ارسال پیام')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadImage = async (file: File) => {
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
try {
|
||||||
|
const { id } = await api.createDevicePost(deviceId, userId, '')
|
||||||
|
await api.uploadPostImage(id, file)
|
||||||
|
await afterPostCreated()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error uploading image:', err)
|
||||||
|
setError('خطا در ارسال تصویر')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGalleryFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) uploadImage(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||||
|
? 'audio/webm;codecs=opus'
|
||||||
|
: ''
|
||||||
|
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
||||||
|
|
||||||
|
chunksRef.current = []
|
||||||
|
recorder.ondataavailable = e => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data)
|
||||||
|
}
|
||||||
|
recorder.onstop = () => {
|
||||||
|
stream.getTracks().forEach(t => t.stop())
|
||||||
|
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' })
|
||||||
|
sendVoice(blob, recordSecondsRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorderRef.current = recorder
|
||||||
|
setRecordSeconds(0)
|
||||||
|
recordSecondsRef.current = 0
|
||||||
|
recorder.start()
|
||||||
|
setRecording(true)
|
||||||
|
|
||||||
|
recordTimerRef.current = setInterval(() => {
|
||||||
|
recordSecondsRef.current += 1
|
||||||
|
setRecordSeconds(recordSecondsRef.current)
|
||||||
|
}, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error starting recording:', err)
|
||||||
|
setError('دسترسی به میکروفون ممکن نیست')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
const recorder = mediaRecorderRef.current
|
||||||
|
if (recorder && recorder.state !== 'inactive') {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
if (recordTimerRef.current) {
|
||||||
|
clearInterval(recordTimerRef.current)
|
||||||
|
recordTimerRef.current = null
|
||||||
|
}
|
||||||
|
setRecording(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVoice = async (blob: Blob, durationSeconds: number) => {
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
try {
|
||||||
|
const { id } = await api.createDevicePost(deviceId, userId, '')
|
||||||
|
await api.uploadPostVoice(id, blob, durationSeconds)
|
||||||
|
await afterPostCreated()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sending voice:', err)
|
||||||
|
setError('خطا در ارسال ویس')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendSnapshot = async () => {
|
||||||
|
const userId = getCurrentUserId()
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
await api.createSensorSnapshotPost(deviceId, userId)
|
||||||
|
await afterPostCreated()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sending snapshot:', err)
|
||||||
|
setError('خطا در ارسال وضعیت سنسورها')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||||
|
{/* Header / actions */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">گفتگو</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={Activity}
|
||||||
|
onClick={sendSnapshot}
|
||||||
|
loading={sending}
|
||||||
|
tooltip="ارسال وضعیت سنسورها و رلهها"
|
||||||
|
>
|
||||||
|
وضعیت سنسورها
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
aria-label="رفرش"
|
||||||
|
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', refreshing && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 px-4 py-4 space-y-3 max-h-[60vh] overflow-y-auto bg-gray-50">
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-10">در حال بارگذاری پیامها...</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-10">
|
||||||
|
هنوز پیامی ثبت نشده است. اولین پیام را بفرستید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="text-center">
|
||||||
|
<Button variant="outline" size="sm" loading={loadingMore} onClick={loadMore}>
|
||||||
|
بارگذاری پیامهای قبلی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{[...posts].reverse().map(post => (
|
||||||
|
<PostBubble key={post.id} post={post} onImageClick={setLightbox} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji row */}
|
||||||
|
{showEmoji && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-4 py-2 border-t border-gray-100 bg-white">
|
||||||
|
{EMOJIS.map(emoji => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setText(t => t + emoji)}
|
||||||
|
className="text-xl p-1 rounded hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div className="border-t border-gray-100 p-3 bg-white">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEmoji(v => !v)}
|
||||||
|
aria-label="ایموجی"
|
||||||
|
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Smile className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleGalleryFile}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => galleryInputRef.current?.click()}
|
||||||
|
aria-label="انتخاب عکس از گالری"
|
||||||
|
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleGalleryFile}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
|
aria-label="عکس با دوربین"
|
||||||
|
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Camera className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="پیام بنویسید..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none px-3 py-2 rounded-xl border border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={recording ? stopRecording : startRecording}
|
||||||
|
aria-label={recording ? 'توقف ضبط' : 'ضبط ویس'}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg transition-colors',
|
||||||
|
recording
|
||||||
|
? 'bg-red-50 text-red-600 animate-pulse'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{recording ? <Square className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendText}
|
||||||
|
disabled={!text.trim() || sending}
|
||||||
|
aria-label="ارسال"
|
||||||
|
className="p-2 rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recording && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-red-600">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||||
|
در حال ضبط... {toPersianDigits(recordSeconds)} ثانیه
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4 cursor-zoom-out"
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={lightbox}
|
||||||
|
alt="نمایش بزرگ تصویر"
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostBubble({ post, onImageClick }: { post: DevicePostDto; onImageClick: (src: string) => void }) {
|
||||||
|
const fullName = `${post.authorName} ${post.authorFamily}`.trim() || 'بدون نام'
|
||||||
|
const isSnapshot = post.postType === 3
|
||||||
|
|
||||||
|
if (isSnapshot) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-[85%] mx-auto bg-emerald-50 border border-emerald-200 rounded-xl px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-xs text-emerald-700">
|
||||||
|
<Activity className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-semibold">وضعیت سنسورها و رلهها</span>
|
||||||
|
<span className="text-emerald-500">•</span>
|
||||||
|
<span>{formatPostTimestamp(post.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-800 whitespace-pre-wrap leading-6">{post.content}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-green-100 to-emerald-200 border border-green-200 overflow-hidden flex items-center justify-center font-bold text-green-700 text-sm shrink-0">
|
||||||
|
{post.authorAvatar ? (
|
||||||
|
<img
|
||||||
|
src={resolveAssetUrl(post.authorAvatar)}
|
||||||
|
alt={fullName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(post.authorName || '؟').charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 bg-white rounded-xl border border-gray-200 px-3 py-2 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-gray-800">{fullName}</span>
|
||||||
|
{post.authorRole != null && (
|
||||||
|
<Badge size="sm" variant="default">
|
||||||
|
{getDeviceMemberRoleLabel(post.authorRole)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-gray-400">{formatPostTimestamp(post.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.content && (
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap mt-1">{post.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.images.length > 0 && (
|
||||||
|
<div className={cn('grid gap-2 mt-2', post.images.length > 1 ? 'grid-cols-2' : 'grid-cols-1')}>
|
||||||
|
{post.images.map(img => (
|
||||||
|
<img
|
||||||
|
key={img.id}
|
||||||
|
src={resolveAssetUrl(img.filePath)}
|
||||||
|
alt={img.fileName}
|
||||||
|
className="rounded-lg w-full object-cover cursor-zoom-in"
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => onImageClick(resolveAssetUrl(img.filePath))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.voiceFilePath && (
|
||||||
|
<audio controls src={resolveAssetUrl(post.voiceFilePath)} className="w-full mt-2 h-9" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { usePathname, useRouter } from '@/lib/router'
|
import { usePathname, useRouter } from '@/lib/router'
|
||||||
import { User, MonitorCog, BarChart3, CloudSun } from 'lucide-react'
|
import { User, MonitorCog, BarChart3, MessageCircle, CloudSun } from 'lucide-react'
|
||||||
import { useDevice } from './DeviceProvider'
|
import { useDevice } from './DeviceProvider'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -31,6 +31,12 @@ const navItems: NavItem[] = [
|
|||||||
route: '/daily-report',
|
route: '/daily-report',
|
||||||
matchPatterns: ['/daily-report', '/calendar', '/day-details']
|
matchPatterns: ['/daily-report', '/calendar', '/day-details']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'گفتگو',
|
||||||
|
icon: MessageCircle,
|
||||||
|
route: '/group',
|
||||||
|
matchPatterns: ['/group']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'آب و هوا',
|
label: 'آب و هوا',
|
||||||
icon: CloudSun,
|
icon: CloudSun,
|
||||||
|
|||||||
395
src/GreenHome.Web/src/components/team/TeamPanel.tsx
Normal file
395
src/GreenHome.Web/src/components/team/TeamPanel.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, DeviceMemberDto, DeviceMemberRole } from '@/lib/api'
|
||||||
|
import { resolveAssetUrl } from '@/lib/api/assets'
|
||||||
|
import { Phone, MessageSquare, Plus, Pencil, Trash2, Users, Camera } from 'lucide-react'
|
||||||
|
import { Button, Badge, EmptyState, Modal, ErrorMessage } from '@/components/common'
|
||||||
|
import { FormInput, MobileInput, Select, Checkbox } from '@/components/forms'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { DEVICE_MEMBER_ROLE_OPTIONS, getDeviceMemberRoleLabel } from '@/features/team/constants'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
|
||||||
|
type TeamPanelProps = {
|
||||||
|
deviceId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberFormState = {
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
mobile: string
|
||||||
|
role: DeviceMemberRole
|
||||||
|
receiveAlerts: boolean
|
||||||
|
avatarPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: MemberFormState = { name: '', family: '', mobile: '', role: 4, receiveAlerts: true }
|
||||||
|
|
||||||
|
function getCurrentUser(): { id: number } | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('user')
|
||||||
|
if (!raw) return null
|
||||||
|
const user = JSON.parse(raw)
|
||||||
|
return typeof user?.id === 'number' ? { id: user.id } : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadgeVariant(role: number): 'success' | 'info' | 'warning' | 'default' {
|
||||||
|
if (role === 0) return 'success'
|
||||||
|
if (role === 1) return 'info'
|
||||||
|
if (role === 2) return 'warning'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamPanel({ deviceId }: TeamPanelProps) {
|
||||||
|
const [members, setMembers] = useState<DeviceMemberDto[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [canManage, setCanManage] = useState(false)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<DeviceMemberDto | null>(null)
|
||||||
|
const [form, setForm] = useState<MemberFormState>(EMPTY_FORM)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const items = await api.getDeviceMembers(deviceId)
|
||||||
|
setMembers(items)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching members:', err)
|
||||||
|
setError('خطا در دریافت اعضای تیم')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [deviceId])
|
||||||
|
|
||||||
|
const fetchCanManage = useCallback(async () => {
|
||||||
|
const user = getCurrentUser()
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
const { canManage } = await api.canManageDeviceMembers(user.id, deviceId)
|
||||||
|
setCanManage(canManage)
|
||||||
|
} catch {
|
||||||
|
// ignore - UI falls back to read-only
|
||||||
|
}
|
||||||
|
}, [deviceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMembers()
|
||||||
|
fetchCanManage()
|
||||||
|
}, [fetchMembers, fetchCanManage])
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditing(null)
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
setError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (member: DeviceMemberDto) => {
|
||||||
|
setEditing(member)
|
||||||
|
setForm({
|
||||||
|
name: member.name,
|
||||||
|
family: member.family,
|
||||||
|
mobile: member.mobile,
|
||||||
|
role: member.role,
|
||||||
|
receiveAlerts: member.receiveAlerts,
|
||||||
|
avatarPath: member.avatarPath,
|
||||||
|
})
|
||||||
|
setError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAvatarFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setAvatarUploading(true)
|
||||||
|
try {
|
||||||
|
const { avatarPath } = await api.uploadMemberAvatar(file)
|
||||||
|
setForm(f => ({ ...f, avatarPath }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error uploading avatar:', err)
|
||||||
|
setError('خطا در آپلود آواتار')
|
||||||
|
} finally {
|
||||||
|
setAvatarUploading(false)
|
||||||
|
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.mobile) {
|
||||||
|
setError('شماره تلفن الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.upsertDeviceMember({
|
||||||
|
deviceId,
|
||||||
|
mobile: form.mobile,
|
||||||
|
name: form.name.trim(),
|
||||||
|
family: form.family.trim(),
|
||||||
|
role: form.role,
|
||||||
|
receiveAlerts: form.receiveAlerts,
|
||||||
|
avatarPath: form.avatarPath,
|
||||||
|
})
|
||||||
|
setShowForm(false)
|
||||||
|
await fetchMembers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving member:', err)
|
||||||
|
setError('خطا در ذخیره عضو')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (member: DeviceMemberDto) => {
|
||||||
|
if (!window.confirm(`«${member.name} ${member.family}» از تیم حذف شود؟`)) return
|
||||||
|
try {
|
||||||
|
await api.deleteDeviceMember(deviceId, member.userId)
|
||||||
|
await fetchMembers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting member:', err)
|
||||||
|
setError('خطا در حذف عضو')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center text-sm text-gray-500 py-12">در حال بارگذاری تیم...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Users className="w-4 h-4 text-green-600" />
|
||||||
|
<span>{toPersianDigits(members.length)} عضو</span>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<Button variant="success" size="sm" icon={Plus} onClick={openAdd}>
|
||||||
|
افزودن عضو
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="هنوز عضوی اضافه نشده است"
|
||||||
|
message="مدیر گلخانه میتواند افراد تیم را با نقش مشخص اضافه کند."
|
||||||
|
action={
|
||||||
|
canManage ? (
|
||||||
|
<Button variant="success" icon={Plus} onClick={openAdd}>
|
||||||
|
افزودن عضو
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{members.map(member => (
|
||||||
|
<MemberCard
|
||||||
|
key={member.userId}
|
||||||
|
member={member}
|
||||||
|
canManage={canManage}
|
||||||
|
onEdit={() => openEdit(member)}
|
||||||
|
onDelete={() => handleDelete(member)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
title={editing ? 'ویرایش عضو' : 'افزودن عضو'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gray-100 border border-gray-200 overflow-hidden flex items-center justify-center text-2xl font-bold text-gray-500">
|
||||||
|
{form.avatarPath ? (
|
||||||
|
<img
|
||||||
|
src={resolveAssetUrl(form.avatarPath)}
|
||||||
|
alt="آواتار"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(form.name || '؟').charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{avatarUploading && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-white/70 flex items-center justify-center text-xs">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarFile}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-green-600 hover:text-green-700"
|
||||||
|
>
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
انتخاب آواتار
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
label="نام"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="مثلاً علی"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="نام خانوادگی"
|
||||||
|
value={form.family}
|
||||||
|
onChange={e => setForm(f => ({ ...f, family: e.target.value }))}
|
||||||
|
placeholder="مثلاً محمدی"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">شماره تلفن</label>
|
||||||
|
<MobileInput value={form.mobile} onValueChange={v => setForm(f => ({ ...f, mobile: v }))} />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="نقش"
|
||||||
|
value={form.role}
|
||||||
|
onChange={e => setForm(f => ({ ...f, role: Number(e.target.value) as DeviceMemberRole }))}
|
||||||
|
>
|
||||||
|
{DEVICE_MEMBER_ROLE_OPTIONS.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.receiveAlerts}
|
||||||
|
onChange={e => setForm(f => ({ ...f, receiveAlerts: e.target.checked }))}
|
||||||
|
label="ارسال پیامک هشدار به این عضو"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button variant="success" fullWidth loading={saving} onClick={handleSubmit}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" fullWidth onClick={() => setShowForm(false)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberCard({
|
||||||
|
member,
|
||||||
|
canManage,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
member: DeviceMemberDto
|
||||||
|
canManage: boolean
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const fullName = `${member.name} ${member.family}`.trim() || 'بدون نام'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm hover:shadow transition-shadow">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-green-100 to-emerald-200 border border-green-200 overflow-hidden flex items-center justify-center text-lg font-bold text-green-700 shrink-0">
|
||||||
|
{member.avatarPath ? (
|
||||||
|
<img
|
||||||
|
src={resolveAssetUrl(member.avatarPath)}
|
||||||
|
alt={fullName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(member.name || '؟').charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h4 className="font-semibold text-gray-900 truncate">{fullName}</h4>
|
||||||
|
<Badge variant={roleBadgeVariant(member.role)} size="sm">
|
||||||
|
{getDeviceMemberRoleLabel(member.role)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1" dir="ltr">
|
||||||
|
{toPersianDigits(member.mobile)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Badge variant={member.receiveAlerts ? 'success' : 'default'} size="sm">
|
||||||
|
{member.receiveAlerts ? 'پیامک فعال' : 'بدون پیامک'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<a
|
||||||
|
href={`tel:${member.mobile}`}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 inline-flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium',
|
||||||
|
'bg-green-50 text-green-700 hover:bg-green-100 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
تماس
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`sms:${member.mobile}`}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 inline-flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium',
|
||||||
|
'bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
پیامک
|
||||||
|
</a>
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label="حذف"
|
||||||
|
className="p-2 rounded-lg text-red-500 hover:text-red-700 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/GreenHome.Web/src/features/team/constants.ts
Normal file
17
src/GreenHome.Web/src/features/team/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const DEVICE_MEMBER_ROLE_LABELS: Record<number, string> = {
|
||||||
|
0: 'مدیر',
|
||||||
|
1: 'ناظر',
|
||||||
|
2: 'مهندس آب و خاک',
|
||||||
|
3: 'تکنسین',
|
||||||
|
4: 'کارگر',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEVICE_MEMBER_ROLE_OPTIONS = Object.entries(DEVICE_MEMBER_ROLE_LABELS).map(([value, label]) => ({
|
||||||
|
value: Number(value),
|
||||||
|
label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function getDeviceMemberRoleLabel(role?: number | null): string {
|
||||||
|
if (role == null) return 'مالک'
|
||||||
|
return DEVICE_MEMBER_ROLE_LABELS[role] ?? 'عضو'
|
||||||
|
}
|
||||||
13
src/GreenHome.Web/src/lib/api/assets.ts
Normal file
13
src/GreenHome.Web/src/lib/api/assets.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a server-relative upload path (e.g. /uploads/avatars/x.png) to a full URL.
|
||||||
|
* - In production the SPA is served from wwwroot so the path is same-origin.
|
||||||
|
* - In development the Vite dev server proxies both /api and /uploads to the backend.
|
||||||
|
* If VITE_API_URL is set (separate API origin), it is used as the base.
|
||||||
|
*/
|
||||||
|
export function resolveAssetUrl(path?: string | null): string {
|
||||||
|
if (!path) return ''
|
||||||
|
if (/^(https?:)?\/\//i.test(path)) return path
|
||||||
|
|
||||||
|
const api = import.meta.env.VITE_API_URL as string | undefined
|
||||||
|
return api ? `${api}${path}` : path
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import type {
|
|||||||
CreateAlertConditionDto,
|
CreateAlertConditionDto,
|
||||||
UpdateAlertConditionDto,
|
UpdateAlertConditionDto,
|
||||||
PowerOutageSummaryDto,
|
PowerOutageSummaryDto,
|
||||||
|
DeviceMemberDto,
|
||||||
|
UpsertDeviceMemberRequest,
|
||||||
|
DevicePostDto,
|
||||||
|
DevicePostImageDto,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||||
@@ -31,8 +35,9 @@ async function http<T>(url: string, init?: RequestInit, retries = MAX_RETRIES):
|
|||||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
|
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
|
||||||
|
|
||||||
const token = getAuthToken()
|
const token = getAuthToken()
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && init?.body instanceof FormData
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...(init?.headers as Record<string, string> | undefined),
|
...(init?.headers as Record<string, string> | undefined),
|
||||||
}
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -153,4 +158,49 @@ export const api = {
|
|||||||
if (endUtc) params.set('endUtc', endUtc)
|
if (endUtc) params.set('endUtc', endUtc)
|
||||||
return http<PowerOutageSummaryDto>(`${API_BASE}/api/PowerOutage/summary?${params.toString()}`)
|
return http<PowerOutageSummaryDto>(`${API_BASE}/api/PowerOutage/summary?${params.toString()}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Device Members (Team)
|
||||||
|
getDeviceMembers: (deviceId: number) =>
|
||||||
|
http<DeviceMemberDto[]>(`${API_BASE}/api/devicemembers?deviceId=${deviceId}`),
|
||||||
|
upsertDeviceMember: (dto: UpsertDeviceMemberRequest) =>
|
||||||
|
http<DeviceMemberDto>(`${API_BASE}/api/devicemembers`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||||
|
deleteDeviceMember: (deviceId: number, userId: number) =>
|
||||||
|
http<void>(`${API_BASE}/api/devicemembers?deviceId=${deviceId}&userId=${userId}`, { method: 'DELETE' }),
|
||||||
|
canManageDeviceMembers: (actorUserId: number, deviceId: number) =>
|
||||||
|
http<{ canManage: boolean }>(`${API_BASE}/api/devicemembers/can-manage?actorUserId=${actorUserId}&deviceId=${deviceId}`),
|
||||||
|
uploadMemberAvatar: (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return http<{ avatarPath: string }>(`${API_BASE}/api/devicemembers/avatar`, { method: 'POST', body: form })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Device Posts (Group)
|
||||||
|
getDevicePosts: (deviceId: number, page = 1, pageSize = 30) =>
|
||||||
|
http<PagedResult<DevicePostDto>>(`${API_BASE}/api/deviceposts?deviceId=${deviceId}&page=${page}&pageSize=${pageSize}`),
|
||||||
|
createDevicePost: (deviceId: number, authorUserId: number, content: string) =>
|
||||||
|
http<{ id: number }>(`${API_BASE}/api/deviceposts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ deviceId, authorUserId, content }),
|
||||||
|
}),
|
||||||
|
uploadPostImage: (postId: number, file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return http<{ imageId: number; filePath: string }>(`${API_BASE}/api/deviceposts/${postId}/images`, { method: 'POST', body: form })
|
||||||
|
},
|
||||||
|
uploadPostVoice: (postId: number, blob: Blob, durationSeconds: number) => {
|
||||||
|
const ext = blob.type.includes('mp4') || blob.type.includes('m4a')
|
||||||
|
? 'm4a'
|
||||||
|
: blob.type.includes('ogg')
|
||||||
|
? 'ogg'
|
||||||
|
: 'webm'
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', blob, `voice.${ext}`)
|
||||||
|
form.append('durationSeconds', String(durationSeconds))
|
||||||
|
return http<{ filePath: string }>(`${API_BASE}/api/deviceposts/${postId}/voice`, { method: 'POST', body: form })
|
||||||
|
},
|
||||||
|
createSensorSnapshotPost: (deviceId: number, authorUserId: number) =>
|
||||||
|
http<{ id: number }>(`${API_BASE}/api/deviceposts/sensor-snapshot`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ deviceId, authorUserId }),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,3 +207,56 @@ export type PowerOutageSummaryDto = {
|
|||||||
outages: PowerOutagePairDto[]
|
outages: PowerOutagePairDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device Members (Team)
|
||||||
|
export type DeviceMemberRole = 0 | 1 | 2 | 3 | 4 // Manager, Supervisor, WaterSoilEngineer, Technician, Worker
|
||||||
|
|
||||||
|
export type DeviceMemberDto = {
|
||||||
|
deviceId: number
|
||||||
|
userId: number
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
mobile: string
|
||||||
|
role: DeviceMemberRole
|
||||||
|
avatarPath?: string
|
||||||
|
receiveAlerts: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpsertDeviceMemberRequest = {
|
||||||
|
deviceId: number
|
||||||
|
mobile: string
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
role: DeviceMemberRole
|
||||||
|
receiveAlerts: boolean
|
||||||
|
avatarPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device Posts (Group)
|
||||||
|
export type DevicePostType = 0 | 1 | 2 | 3 // Text, Image, Voice, SensorSnapshot
|
||||||
|
|
||||||
|
export type DevicePostImageDto = {
|
||||||
|
id: number
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
contentType: string
|
||||||
|
fileSize: number
|
||||||
|
uploadedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DevicePostDto = {
|
||||||
|
id: number
|
||||||
|
deviceId: number
|
||||||
|
authorUserId: number
|
||||||
|
authorName: string
|
||||||
|
authorFamily: string
|
||||||
|
authorAvatar?: string
|
||||||
|
authorRole?: DeviceMemberRole
|
||||||
|
content: string
|
||||||
|
postType: DevicePostType
|
||||||
|
voiceFilePath?: string
|
||||||
|
voiceDurationSeconds?: number
|
||||||
|
images: DevicePostImageDto[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export default defineConfig({
|
|||||||
target: process.env.VITE_DEV_API_URL ?? 'http://127.0.0.1:5064',
|
target: process.env.VITE_DEV_API_URL ?? 'http://127.0.0.1:5064',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
// Uploaded files (avatars, post images, voice) live on the backend too.
|
||||||
|
'/uploads': {
|
||||||
|
target: process.env.VITE_DEV_API_URL ?? 'http://127.0.0.1:5064',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user