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);
|
||||
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 string AuthorName { 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 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 DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
@@ -43,3 +48,9 @@ public sealed class DevicePostFilter
|
||||
public int Page { get; set; } = 1;
|
||||
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 DeleteImageAsync(int imageId, 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));
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// نوع پیام در گروه مجازی دستگاه
|
||||
/// </summary>
|
||||
public enum DevicePostType
|
||||
{
|
||||
/// <summary>متن</summary>
|
||||
Text = 0,
|
||||
|
||||
/// <summary>تصویر</summary>
|
||||
Image = 1,
|
||||
|
||||
/// <summary>ویس</summary>
|
||||
Voice = 2,
|
||||
|
||||
/// <summary>اسنپشات وضعیت سنسورها و رلهها</summary>
|
||||
SensorSnapshot = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// پستهای گروه مجازی دستگاه (تایملاین مشترک)
|
||||
/// </summary>
|
||||
@@ -24,6 +42,21 @@ public sealed class DevicePost
|
||||
/// </summary>
|
||||
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>
|
||||
|
||||
@@ -89,6 +89,8 @@ public sealed class DeviceSettings
|
||||
/// </summary>
|
||||
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)]
|
||||
public string SmsRecievers { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -11,5 +11,25 @@ public sealed class DeviceUser
|
||||
/// آیا این کاربر باید هشدارهای این دستگاه را دریافت کند؟
|
||||
/// </summary>
|
||||
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.HasKey(x => new { x.DeviceId, x.UserId });
|
||||
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)
|
||||
.WithMany(d => d.DeviceUsers)
|
||||
.HasForeignKey(x => x.DeviceId)
|
||||
@@ -383,6 +387,8 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
b.ToTable("DevicePosts");
|
||||
b.HasKey(x => x.Id);
|
||||
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)
|
||||
.WithMany()
|
||||
.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")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("PostType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("VoiceDurationSeconds")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("VoiceFilePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuthorUserId");
|
||||
@@ -708,11 +718,28 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
b.Property<int>("UserId")
|
||||
.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")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("DeviceId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
@@ -48,6 +48,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IDeviceSettingsService, DeviceSettingsService>();
|
||||
services.AddScoped<IDeviceTokenService, DeviceTokenService>();
|
||||
services.AddScoped<IDevicePostService, DevicePostService>();
|
||||
services.AddScoped<IDeviceMemberService, DeviceMemberService>();
|
||||
|
||||
// Telemetry
|
||||
services.AddScoped<ITelemetryService, TelemetryService>();
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class AlertService : IAlertService
|
||||
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
|
||||
|
||||
// 3. Build recipient list
|
||||
var recipients = BuildRecipients(device, settings);
|
||||
var recipients = BuildRecipients(device);
|
||||
if (recipients.Length == 0)
|
||||
{
|
||||
_logger.LogInformation("No recipients for device: DeviceId={DeviceId}", deviceId);
|
||||
@@ -220,11 +220,13 @@ public sealed class AlertService : IAlertService
|
||||
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(",",
|
||||
(device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
|
||||
.Split(',')
|
||||
new[] { device.User.Mobile }
|
||||
.Concat(device.DeviceUsers.Select(du => du.User.Mobile))
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct()
|
||||
|
||||
@@ -34,9 +34,9 @@ public sealed class AuthService : IAuthService
|
||||
SendCodeRequest request,
|
||||
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
|
||||
{
|
||||
@@ -117,7 +117,7 @@ public sealed class AuthService : IAuthService
|
||||
VerifyCodeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mobile = NormalizeMobile(request.Mobile);
|
||||
var mobile = Domain.MobileNormalizer.Normalize(request.Mobile);
|
||||
var code = request.Code.Trim();
|
||||
|
||||
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)
|
||||
{
|
||||
var normalizedMobile = NormalizeMobile(mobile);
|
||||
var normalizedMobile = Domain.MobileNormalizer.Normalize(mobile);
|
||||
var lastCode = await _dbContext.VerificationCodes
|
||||
.Where(v => v.Mobile == normalizedMobile && !v.IsUsed)
|
||||
.OrderByDescending(v => v.CreatedAt)
|
||||
@@ -252,20 +252,4 @@ public sealed class AuthService : IAuthService
|
||||
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)
|
||||
.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>
|
||||
{
|
||||
@@ -56,7 +57,11 @@ public sealed class DevicePostService : IDevicePostService
|
||||
.AsNoTracking()
|
||||
.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(
|
||||
@@ -135,6 +140,10 @@ public sealed class DevicePostService : IDevicePostService
|
||||
};
|
||||
|
||||
_dbContext.DevicePostImages.Add(image);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(post.Content))
|
||||
post.PostType = Domain.DevicePostType.Image;
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return image.Id;
|
||||
@@ -157,6 +166,15 @@ public sealed class DevicePostService : IDevicePostService
|
||||
int deviceId,
|
||||
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
|
||||
.AnyAsync(d => d.Id == deviceId && d.UserId == userId, cancellationToken);
|
||||
|
||||
@@ -168,4 +186,137 @@ public sealed class DevicePostService : IDevicePostService
|
||||
|
||||
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()
|
||||
.Include(d => d.User);
|
||||
|
||||
if (user.Role == Domain.UserRole.Normal)
|
||||
query = query.Where(d => d.UserId == user.Id);
|
||||
else if (user.Role == Domain.UserRole.Supervisor)
|
||||
query = query.Where(d => d.DeviceUsers.Any(du => du.UserId == user.Id));
|
||||
if (user.Role == Domain.UserRole.Normal || user.Role == Domain.UserRole.Supervisor)
|
||||
query = query.Where(d => d.UserId == user.Id || d.DeviceUsers.Any(du => du.UserId == user.Id));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Search))
|
||||
{
|
||||
|
||||
@@ -51,6 +51,7 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
||||
var entity = await _dbContext.DeviceSettings
|
||||
.Include(x => x.Relays)
|
||||
.ThenInclude(r => r.Rules)
|
||||
.Include(x => x.Device)
|
||||
.FirstOrDefaultAsync(x => x.DeviceId == dto.DeviceId, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
@@ -59,6 +60,9 @@ public sealed class DeviceSettingsService : IDeviceSettingsService
|
||||
_mapper.Map(dto, entity);
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dto.DeviceName))
|
||||
entity.Device.DeviceName = dto.DeviceName.Trim();
|
||||
|
||||
SyncRelays(entity, dto.Relays ?? new List<DeviceRelayDto>());
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -11,6 +11,7 @@ import DayDetailsPage from '@/app/day-details/page'
|
||||
import WeatherPage from '@/app/weather/page'
|
||||
import ProfilePage from '@/app/profile/page'
|
||||
import DevicePage from '@/app/device/page'
|
||||
import GroupPage from '@/app/group/page'
|
||||
|
||||
function DeviceRedirect() {
|
||||
const location = useLocation()
|
||||
@@ -34,6 +35,7 @@ export default function App() {
|
||||
<Route path="/weather" element={<WeatherPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/device" element={<DevicePage />} />
|
||||
<Route path="/group" element={<GroupPage />} />
|
||||
<Route path="/device-settings" element={<DeviceRedirect />} />
|
||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"use client"
|
||||
import { useState, Suspense } from 'react'
|
||||
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 { AppShell } from '@/components/layout'
|
||||
import { RelaySettingsTab } from '@/components/device-settings/RelaySettingsTab'
|
||||
import { DeviceInfoTab } from '@/components/device-settings/DeviceInfoTab'
|
||||
import { AlertSettingsTab } from '@/components/alert-settings/AlertSettingsTab'
|
||||
import { TeamPanel } from '@/components/team/TeamPanel'
|
||||
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 }[] = [
|
||||
{ key: 'settings', label: 'تنظیمات', icon: Info },
|
||||
{ key: 'relays', label: 'رلهها', icon: Plug },
|
||||
{ key: 'alerts', label: 'هشدارها', icon: Bell },
|
||||
{ key: 'team', label: 'تیم', icon: Users },
|
||||
]
|
||||
|
||||
function DevicePageContent() {
|
||||
@@ -57,6 +59,7 @@ function DevicePageContent() {
|
||||
{tab === 'relays' && <RelaySettingsTab deviceId={deviceId} />}
|
||||
{tab === 'alerts' && <AlertSettingsTab deviceId={deviceId} />}
|
||||
{tab === 'settings' && <DeviceInfoTab deviceId={deviceId} deviceName={deviceName} />}
|
||||
{tab === 'team' && <TeamPanel deviceId={deviceId} />}
|
||||
</div>
|
||||
</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'
|
||||
|
||||
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 Loading from '@/components/Loading'
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [smsInput, setSmsInput] = useState('')
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
@@ -72,29 +71,6 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
||||
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 () => {
|
||||
if (!settings) return
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
@@ -171,14 +145,29 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* نوع دستگاه */}
|
||||
{/* مشخصات دستگاه */}
|
||||
<SectionCard
|
||||
icon={Sprout}
|
||||
iconClass="bg-indigo-100 text-indigo-600"
|
||||
title="نوع دستگاه"
|
||||
subtitle="مشخصات محصول و ابعاد فضای کشت"
|
||||
title="مشخصات دستگاه"
|
||||
subtitle="نام گلخانه، نوع محصول و شماره سیمکارت دستگاه"
|
||||
>
|
||||
<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="نوع محصول">
|
||||
<input className={FIELD_STYLES.indigo} value={settings.productType} onChange={(e) => update({ productType: e.target.value })} />
|
||||
</SettingField>
|
||||
@@ -197,95 +186,15 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
||||
</div>
|
||||
</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}
|
||||
iconClass="bg-amber-100 text-amber-600"
|
||||
title="اطلاعرسانی و هشدار"
|
||||
subtitle="گیرندگان پیامک و محدودیت ارسال برای جلوگیری از مزاحمت"
|
||||
subtitle="محدودیت ارسال برای جلوگیری از مزاحمت"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<SettingField
|
||||
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">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<SettingField
|
||||
label="فاصله ارسال پیامک"
|
||||
hint="حداقل فاصله بین دو پیامک (دقیقه)"
|
||||
@@ -318,7 +227,7 @@ export function DeviceInfoTab({ deviceId, deviceName }: { deviceId: number; devi
|
||||
</SettingField>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</SectionCard> */}
|
||||
|
||||
<div className="flex justify-center pb-4">
|
||||
<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"
|
||||
|
||||
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 { cn } from '@/lib/utils'
|
||||
|
||||
@@ -31,6 +31,12 @@ const navItems: NavItem[] = [
|
||||
route: '/daily-report',
|
||||
matchPatterns: ['/daily-report', '/calendar', '/day-details']
|
||||
},
|
||||
{
|
||||
label: 'گفتگو',
|
||||
icon: MessageCircle,
|
||||
route: '/group',
|
||||
matchPatterns: ['/group']
|
||||
},
|
||||
{
|
||||
label: 'آب و هوا',
|
||||
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,
|
||||
UpdateAlertConditionDto,
|
||||
PowerOutageSummaryDto,
|
||||
DeviceMemberDto,
|
||||
UpsertDeviceMemberRequest,
|
||||
DevicePostDto,
|
||||
DevicePostImageDto,
|
||||
} from './types'
|
||||
|
||||
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 token = getAuthToken()
|
||||
const isFormData = typeof FormData !== 'undefined' && init?.body instanceof FormData
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
}
|
||||
if (token) {
|
||||
@@ -153,4 +158,49 @@ export const api = {
|
||||
if (endUtc) params.set('endUtc', endUtc)
|
||||
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[]
|
||||
}
|
||||
|
||||
// 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',
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user