add chat and users

This commit is contained in:
2026-09-10 19:09:36 +03:30
parent 91ad61bdee
commit 8eb465c0e7
40 changed files with 4888 additions and 148 deletions

View 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 = "خطا در آپلود آواتار" });
}
}
}

View File

@@ -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

View 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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View 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
}

View File

@@ -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>
@@ -23,6 +41,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>
/// تصاویر پیوست

View File

@@ -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;

View File

@@ -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; }
}

View 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;
}
}

View File

@@ -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)

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

@@ -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>();

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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";
}
}

View File

@@ -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))
{

View File

@@ -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);

View File

@@ -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 />} />

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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}>

View 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>
)
}

View File

@@ -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,

View 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>
)
}

View 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] ?? 'عضو'
}

View 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
}

View File

@@ -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 }),
}),
}

View File

@@ -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
}

View File

@@ -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: {