update: pwa dll

main
muamars 2026-03-16 14:50:14 +07:00
parent fb75e82964
commit a21c903ea7
26 changed files with 3569 additions and 1427 deletions

View File

@ -15,17 +15,43 @@ namespace eSPJ.Controllers.SpjDriverUpstController
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly DetailPenjemputanService _detailService; private readonly DetailPenjemputanService _detailService;
private readonly ILogger<DetailPenjemputanController> _logger; private readonly ILogger<DetailPenjemputanController> _logger;
private readonly IWebHostEnvironment _env;
public DetailPenjemputanController( public DetailPenjemputanController(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IConfiguration configuration, IConfiguration configuration,
DetailPenjemputanService detailService, DetailPenjemputanService detailService,
ILogger<DetailPenjemputanController> logger) ILogger<DetailPenjemputanController> logger,
IWebHostEnvironment env)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_configuration = configuration; _configuration = configuration;
_detailService = detailService; _detailService = detailService;
_logger = logger; _logger = logger;
_env = env;
}
private static string ResolveDraftKey(string? draftKey, string? sessionKey, string? spjDetailId = null, string? lokasiAngkutId = null)
{
var rawKey = !string.IsNullOrWhiteSpace(draftKey)
? draftKey
: !string.IsNullOrWhiteSpace(sessionKey)
? sessionKey
: $"non-tps-{spjDetailId}-{lokasiAngkutId}";
return string.Concat((rawKey ?? string.Empty).Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
}
private string GetUploadDirectory(string dateFolder)
{
var uploadDir = Path.Combine(_env.ContentRootPath, "uploads", "penjemputan", dateFolder);
Directory.CreateDirectory(uploadDir);
return uploadDir;
}
private static string BuildUploadUrl(string dateFolder, string fileName)
{
return $"/uploads/penjemputan/{dateFolder}/{fileName}";
} }
[HttpGet("")] [HttpGet("")]
@ -90,6 +116,494 @@ namespace eSPJ.Controllers.SpjDriverUpstController
} }
} }
[HttpPost("save-draft-non-tps")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> SaveDraftNonTps([FromBody] DraftSaveRequest request)
{
if (request == null)
return BadRequest(new DraftSaveResponse { Success = false, Message = "Request tidak valid." });
request.DraftKey = ResolveDraftKey(request.DraftKey, request.SessionKey, request.SpjDetailId, request.LokasiAngkutId);
request.SessionKey = request.DraftKey;
if (string.IsNullOrWhiteSpace(request.DraftKey))
return BadRequest(new DraftSaveResponse { Success = false, Message = "Draft key tidak valid." });
var result = await _detailService.SaveDraftNonTpsAsync(request);
return result.Success ? Ok(result) : StatusCode(500, result);
}
[HttpGet("load-draft-non-tps")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> LoadDraftNonTps([FromQuery] string? draftKey = null, [FromQuery] string? sessionKey = null)
{
var key = ResolveDraftKey(draftKey, sessionKey);
if (string.IsNullOrWhiteSpace(key))
return Ok(new DraftLoadResponse { Success = true, HasDraft = false, Message = "Draft key kosong." });
var result = await _detailService.LoadDraftNonTpsAsync(key);
return Ok(result);
}
[HttpDelete("delete-draft-non-tps")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> DeleteDraftNonTps([FromQuery] string? draftKey = null, [FromQuery] string? sessionKey = null)
{
var key = ResolveDraftKey(draftKey, sessionKey);
if (string.IsNullOrWhiteSpace(key))
return Ok(new { success = false });
var ok = await _detailService.DeleteDraftNonTpsAsync(key);
return Ok(new { success = ok });
}
[HttpPost("save-draft")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> SaveDraft([FromBody] DraftSaveRequest request)
{
if (request == null)
return BadRequest(new DraftSaveResponse { Success = false, Message = "Request tidak valid." });
request.DraftKey = ResolveDraftKey(request.DraftKey, request.SessionKey, request.SpjDetailId, request.LokasiAngkutId);
request.SessionKey = request.DraftKey;
if (string.IsNullOrWhiteSpace(request.DraftKey))
return BadRequest(new DraftSaveResponse { Success = false, Message = "Draft key tidak valid." });
var result = await _detailService.SaveDraftTpsAsync(request);
return result.Success ? Ok(result) : StatusCode(500, result);
}
[HttpGet("load-draft")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> LoadDraft([FromQuery] string? draftKey = null, [FromQuery] string? sessionKey = null)
{
var key = ResolveDraftKey(draftKey, sessionKey);
if (string.IsNullOrWhiteSpace(key))
return Ok(new DraftLoadResponse { Success = true, HasDraft = false, Message = "Draft key kosong." });
var result = await _detailService.LoadDraftTpsAsync(key);
return Ok(result);
}
[HttpDelete("delete-draft")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> DeleteDraft([FromQuery] string? draftKey = null, [FromQuery] string? sessionKey = null)
{
var key = ResolveDraftKey(draftKey, sessionKey);
if (string.IsNullOrWhiteSpace(key))
return Ok(new { success = false });
var ok = await _detailService.DeleteDraftTpsAsync(key);
return Ok(new { success = ok });
}
[HttpPost("upload-foto-kedatangan-non-tps")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UploadFotoKedatanganNonTps(
[FromForm] List<IFormFile>? FotoKedatangan,
[FromForm] string? DraftKey,
[FromForm] string? SessionKey,
[FromForm] string? SpjDetailId,
[FromForm] string? LokasiAngkutId,
[FromForm] string? WaktuKedatangan,
[FromForm] string? Latitude,
[FromForm] string? Longitude,
[FromForm] string? AlamatJalan)
{
if (FotoKedatangan == null || FotoKedatangan.Count == 0)
return BadRequest(new { success = false, message = "Tidak ada foto." });
var dateFolder = DateTime.Now.ToString("yyyy-MM-dd");
var uploadDir = GetUploadDirectory(dateFolder);
var fileNames = new List<string>();
foreach (var file in FotoKedatangan)
{
if (file.Length == 0) continue;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var name = $"kedatangan_{Guid.NewGuid()}{ext}";
var path = Path.Combine(uploadDir, name);
await using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
fileNames.Add(name);
}
var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n)).ToList();
var resolvedDraftKey = ResolveDraftKey(DraftKey, SessionKey, SpjDetailId, LokasiAngkutId);
if (!string.IsNullOrWhiteSpace(resolvedDraftKey))
{
var loadResult = await _detailService.LoadDraftNonTpsAsync(resolvedDraftKey);
var draft = loadResult.Draft ?? new DraftPenjemputanNonTps { SessionKey = resolvedDraftKey, SpjDetailId = SpjDetailId ?? string.Empty, LokasiAngkutId = LokasiAngkutId ?? string.Empty };
draft.FotoKedatanganFileNames = fileUrls;
draft.FotoKedatanganUploaded = true;
if (!string.IsNullOrWhiteSpace(SpjDetailId)) draft.SpjDetailId = SpjDetailId;
if (!string.IsNullOrWhiteSpace(LokasiAngkutId)) draft.LokasiAngkutId = LokasiAngkutId;
if (!string.IsNullOrWhiteSpace(WaktuKedatangan)) draft.WaktuKedatangan = WaktuKedatangan;
if (!string.IsNullOrWhiteSpace(Latitude)) draft.Latitude = Latitude;
if (!string.IsNullOrWhiteSpace(Longitude)) draft.Longitude = Longitude;
if (!string.IsNullOrWhiteSpace(AlamatJalan)) draft.AlamatJalan = AlamatJalan;
await _detailService.SaveDraftNonTpsAsync(new DraftSaveRequest
{
DraftKey = resolvedDraftKey,
SessionKey = resolvedDraftKey,
LokasiAngkutId = draft.LokasiAngkutId,
SpjDetailId = draft.SpjDetailId,
Latitude = draft.Latitude,
Longitude = draft.Longitude,
AlamatJalan = draft.AlamatJalan,
WaktuKedatangan = draft.WaktuKedatangan,
FotoKedatanganFileNames = fileUrls,
FotoKedatanganUploaded = true,
Timbangan = draft.Timbangan,
TotalOrganik = draft.TotalOrganik,
TotalAnorganik = draft.TotalAnorganik,
TotalResidu = draft.TotalResidu,
TotalTimbangan = draft.TotalTimbangan,
FotoPetugasFileNames = draft.FotoPetugasFileNames,
FotoPetugasUploaded = draft.FotoPetugasUploaded,
NamaPetugas = draft.NamaPetugas
});
}
return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto kedatangan berhasil diupload." });
}
[HttpPost("upload-foto-timbangan-non-tps")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UploadFotoTimbanganNonTps(
[FromForm] IFormFile? FotoTimbangan,
[FromForm] string? DraftKey,
[FromForm] string? SessionKey,
[FromForm] string? SpjDetailId,
[FromForm] string? LokasiAngkutId,
[FromForm] int ItemIndex,
[FromForm] string? JenisSampah,
[FromForm] decimal Berat)
{
if (FotoTimbangan == null || FotoTimbangan.Length == 0)
return BadRequest(new { success = false, message = "Tidak ada foto." });
var dateFolder = DateTime.Now.ToString("yyyy-MM-dd");
var uploadDir = GetUploadDirectory(dateFolder);
var ext = Path.GetExtension(FotoTimbangan.FileName).ToLowerInvariant();
var jenisSafe = (JenisSampah ?? "residu").ToLowerInvariant();
var beratStr = Berat.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture).Replace('.', '_');
var name = $"timbangan{ItemIndex + 1}-{jenisSafe}-{beratStr}{ext}";
var filePath = Path.Combine(uploadDir, name);
await using var stream = new FileStream(filePath, FileMode.Create);
await FotoTimbangan.CopyToAsync(stream);
var fileUrl = BuildUploadUrl(dateFolder, name);
var resolvedDraftKey = ResolveDraftKey(DraftKey, SessionKey, SpjDetailId, LokasiAngkutId);
if (!string.IsNullOrWhiteSpace(resolvedDraftKey))
{
var loadResult = await _detailService.LoadDraftNonTpsAsync(resolvedDraftKey);
var draft = loadResult.Draft ?? new DraftPenjemputanNonTps { SessionKey = resolvedDraftKey, SpjDetailId = SpjDetailId ?? string.Empty, LokasiAngkutId = LokasiAngkutId ?? string.Empty };
while (draft.Timbangan.Count <= ItemIndex)
draft.Timbangan.Add(new DraftTimbanganItem());
if (!string.IsNullOrWhiteSpace(SpjDetailId)) draft.SpjDetailId = SpjDetailId;
if (!string.IsNullOrWhiteSpace(LokasiAngkutId)) draft.LokasiAngkutId = LokasiAngkutId;
draft.Timbangan[ItemIndex] = new DraftTimbanganItem
{
FotoFileName = fileUrl,
JenisSampah = JenisSampah ?? "Residu",
Berat = Berat,
Uploaded = true
};
await _detailService.SaveDraftNonTpsAsync(new DraftSaveRequest
{
DraftKey = resolvedDraftKey,
SessionKey = resolvedDraftKey,
LokasiAngkutId = draft.LokasiAngkutId,
SpjDetailId = draft.SpjDetailId,
Latitude = draft.Latitude,
Longitude = draft.Longitude,
AlamatJalan = draft.AlamatJalan,
WaktuKedatangan = draft.WaktuKedatangan,
FotoKedatanganFileNames = draft.FotoKedatanganFileNames,
FotoKedatanganUploaded = draft.FotoKedatanganUploaded,
Timbangan = draft.Timbangan,
TotalOrganik = draft.TotalOrganik,
TotalAnorganik = draft.TotalAnorganik,
TotalResidu = draft.TotalResidu,
TotalTimbangan = draft.TotalTimbangan,
FotoPetugasFileNames = draft.FotoPetugasFileNames,
FotoPetugasUploaded = draft.FotoPetugasUploaded,
NamaPetugas = draft.NamaPetugas
});
}
return Ok(new { success = true, fileName = name, fileUrl, message = $"Foto timbangan #{ItemIndex + 1} berhasil diupload." });
}
[HttpPost("upload-foto-petugas-non-tps")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UploadFotoPetugasNonTps(
[FromForm] List<IFormFile>? FotoPetugas,
[FromForm] string? DraftKey,
[FromForm] string? SessionKey,
[FromForm] string? SpjDetailId,
[FromForm] string? LokasiAngkutId,
[FromForm] string? NamaPetugas)
{
if (FotoPetugas == null || FotoPetugas.Count == 0)
return BadRequest(new { success = false, message = "Tidak ada foto." });
var dateFolder = DateTime.Now.ToString("yyyy-MM-dd");
var uploadDir = GetUploadDirectory(dateFolder);
var fileNames = new List<string>();
foreach (var file in FotoPetugas)
{
if (file.Length == 0) continue;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var name = $"petugas_{Guid.NewGuid()}{ext}";
var path = Path.Combine(uploadDir, name);
await using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
fileNames.Add(name);
}
var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n)).ToList();
var resolvedDraftKey = ResolveDraftKey(DraftKey, SessionKey, SpjDetailId, LokasiAngkutId);
if (!string.IsNullOrWhiteSpace(resolvedDraftKey))
{
var loadResult = await _detailService.LoadDraftNonTpsAsync(resolvedDraftKey);
var draft = loadResult.Draft ?? new DraftPenjemputanNonTps { SessionKey = resolvedDraftKey, SpjDetailId = SpjDetailId ?? string.Empty, LokasiAngkutId = LokasiAngkutId ?? string.Empty };
draft.FotoPetugasFileNames = fileUrls;
draft.FotoPetugasUploaded = true;
if (!string.IsNullOrWhiteSpace(SpjDetailId)) draft.SpjDetailId = SpjDetailId;
if (!string.IsNullOrWhiteSpace(LokasiAngkutId)) draft.LokasiAngkutId = LokasiAngkutId;
if (!string.IsNullOrWhiteSpace(NamaPetugas)) draft.NamaPetugas = NamaPetugas;
await _detailService.SaveDraftNonTpsAsync(new DraftSaveRequest
{
DraftKey = resolvedDraftKey,
SessionKey = resolvedDraftKey,
LokasiAngkutId = draft.LokasiAngkutId,
SpjDetailId = draft.SpjDetailId,
Latitude = draft.Latitude,
Longitude = draft.Longitude,
AlamatJalan = draft.AlamatJalan,
WaktuKedatangan = draft.WaktuKedatangan,
FotoKedatanganFileNames = draft.FotoKedatanganFileNames,
FotoKedatanganUploaded = draft.FotoKedatanganUploaded,
Timbangan = draft.Timbangan,
TotalOrganik = draft.TotalOrganik,
TotalAnorganik = draft.TotalAnorganik,
TotalResidu = draft.TotalResidu,
TotalTimbangan = draft.TotalTimbangan,
FotoPetugasFileNames = fileUrls,
FotoPetugasUploaded = true,
NamaPetugas = draft.NamaPetugas
});
}
return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." });
}
[HttpPost("upload-foto-kedatangan")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UploadFotoKedatangan(
[FromForm] List<IFormFile>? FotoKedatangan,
[FromForm] string? DraftKey,
[FromForm] string? SessionKey,
[FromForm] string? SpjDetailId,
[FromForm] string? LokasiAngkutId,
[FromForm] string? WaktuKedatangan,
[FromForm] string? Latitude,
[FromForm] string? Longitude,
[FromForm] string? AlamatJalan)
{
if (FotoKedatangan == null || FotoKedatangan.Count == 0)
return BadRequest(new { success = false, message = "Tidak ada foto." });
var dateFolder = DateTime.Now.ToString("yyyy-MM-dd");
var uploadDir = GetUploadDirectory(dateFolder);
var fileNames = new List<string>();
foreach (var file in FotoKedatangan)
{
if (file.Length == 0) continue;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var name = $"kedatangan_{Guid.NewGuid()}{ext}";
var path = Path.Combine(uploadDir, name);
await using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
fileNames.Add(name);
}
var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n)).ToList();
var resolvedDraftKeyTps = ResolveDraftKey(DraftKey, SessionKey, SpjDetailId, LokasiAngkutId);
if (!string.IsNullOrWhiteSpace(resolvedDraftKeyTps))
{
var loadResult = await _detailService.LoadDraftTpsAsync(resolvedDraftKeyTps);
var draft = loadResult.Draft ?? new DraftPenjemputanNonTps { SessionKey = resolvedDraftKeyTps, SpjDetailId = SpjDetailId ?? string.Empty, LokasiAngkutId = LokasiAngkutId ?? string.Empty };
draft.FotoKedatanganFileNames = fileUrls;
draft.FotoKedatanganUploaded = true;
if (!string.IsNullOrWhiteSpace(SpjDetailId)) draft.SpjDetailId = SpjDetailId;
if (!string.IsNullOrWhiteSpace(LokasiAngkutId)) draft.LokasiAngkutId = LokasiAngkutId;
if (!string.IsNullOrWhiteSpace(WaktuKedatangan)) draft.WaktuKedatangan = WaktuKedatangan;
if (!string.IsNullOrWhiteSpace(Latitude)) draft.Latitude = Latitude;
if (!string.IsNullOrWhiteSpace(Longitude)) draft.Longitude = Longitude;
if (!string.IsNullOrWhiteSpace(AlamatJalan)) draft.AlamatJalan = AlamatJalan;
await _detailService.SaveDraftTpsAsync(new DraftSaveRequest
{
DraftKey = resolvedDraftKeyTps,
SessionKey = resolvedDraftKeyTps,
LokasiAngkutId = draft.LokasiAngkutId,
SpjDetailId = draft.SpjDetailId,
Latitude = draft.Latitude,
Longitude = draft.Longitude,
AlamatJalan = draft.AlamatJalan,
WaktuKedatangan = draft.WaktuKedatangan,
FotoKedatanganFileNames = fileUrls,
FotoKedatanganUploaded = true,
Timbangan = draft.Timbangan,
TotalOrganik = draft.TotalOrganik,
TotalAnorganik = draft.TotalAnorganik,
TotalResidu = draft.TotalResidu,
TotalTimbangan = draft.TotalTimbangan,
FotoPetugasFileNames = draft.FotoPetugasFileNames,
FotoPetugasUploaded = draft.FotoPetugasUploaded,
NamaPetugas = draft.NamaPetugas
});
}
return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto kedatangan berhasil diupload." });
}
[HttpPost("upload-foto-timbangan")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UploadFotoTimbangan(
[FromForm] IFormFile? FotoTimbangan,
[FromForm] string? DraftKey,
[FromForm] string? SessionKey,
[FromForm] string? SpjDetailId,
[FromForm] string? LokasiAngkutId,
[FromForm] int ItemIndex,
[FromForm] string? JenisSampah,
[FromForm] decimal Berat)
{
if (FotoTimbangan == null || FotoTimbangan.Length == 0)
return BadRequest(new { success = false, message = "Tidak ada foto." });
var dateFolder = DateTime.Now.ToString("yyyy-MM-dd");
var uploadDir = GetUploadDirectory(dateFolder);
var ext = Path.GetExtension(FotoTimbangan.FileName).ToLowerInvariant();
var jenisSafe = (JenisSampah ?? "residu").ToLowerInvariant();
var beratStr = Berat.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture).Replace('.', '_');
var name = $"timbangan{ItemIndex + 1}-{jenisSafe}-{beratStr}{ext}";
var filePath = Path.Combine(uploadDir, name);
await using var stream = new FileStream(filePath, FileMode.Create);
await FotoTimbangan.CopyToAsync(stream);
var fileUrl = BuildUploadUrl(dateFolder, name);
var resolvedDraftKeyTps = ResolveDraftKey(DraftKey, SessionKey, SpjDetailId, LokasiAngkutId);
if (!string.IsNullOrWhiteSpace(resolvedDraftKeyTps))
{
var loadResult = await _detailService.LoadDraftTpsAsync(resolvedDraftKeyTps);
var draft = loadResult.Draft ?? new DraftPenjemputanNonTps { SessionKey = resolvedDraftKeyTps, SpjDetailId = SpjDetailId ?? string.Empty, LokasiAngkutId = LokasiAngkutId ?? string.Empty };
while (draft.Timbangan.Count <= ItemIndex)
draft.Timbangan.Add(new DraftTimbanganItem());
if (!string.IsNullOrWhiteSpace(SpjDetailId)) draft.SpjDetailId = SpjDetailId;
if (!string.IsNullOrWhiteSpace(LokasiAngkutId)) draft.LokasiAngkutId = LokasiAngkutId;
draft.Timbangan[ItemIndex] = new DraftTimbanganItem
{
FotoFileName = fileUrl,
JenisSampah = JenisSampah ?? "Residu",
Berat = Berat,
Uploaded = true
};
await _detailService.SaveDraftTpsAsync(new DraftSaveRequest
{
DraftKey = resolvedDraftKeyTps,
SessionKey = resolvedDraftKeyTps,
LokasiAngkutId = draft.LokasiAngkutId,
SpjDetailId = draft.SpjDetailId,
Latitude = draft.Latitude,
Longitude = draft.Longitude,
AlamatJalan = draft.AlamatJalan,
WaktuKedatangan = draft.WaktuKedatangan,
FotoKedatanganFileNames = draft.FotoKedatanganFileNames,
FotoKedatanganUploaded = draft.FotoKedatanganUploaded,
Timbangan = draft.Timbangan,
TotalOrganik = draft.TotalOrganik,
TotalAnorganik = draft.TotalAnorganik,
TotalResidu = draft.TotalResidu,
TotalTimbangan = draft.TotalTimbangan,
FotoPetugasFileNames = draft.FotoPetugasFileNames,
FotoPetugasUploaded = draft.FotoPetugasUploaded,
NamaPetugas = draft.NamaPetugas
});
}
return Ok(new { success = true, fileName = name, fileUrl, message = $"Foto timbangan #{ItemIndex + 1} berhasil diupload." });
}
[HttpPost("upload-foto-petugas")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UploadFotoPetugas(
[FromForm] List<IFormFile>? FotoPetugas,
[FromForm] string? DraftKey,
[FromForm] string? SessionKey,
[FromForm] string? SpjDetailId,
[FromForm] string? LokasiAngkutId,
[FromForm] string? NamaPetugas)
{
if (FotoPetugas == null || FotoPetugas.Count == 0)
return BadRequest(new { success = false, message = "Tidak ada foto." });
var dateFolder = DateTime.Now.ToString("yyyy-MM-dd");
var uploadDir = GetUploadDirectory(dateFolder);
var fileNames = new List<string>();
foreach (var file in FotoPetugas)
{
if (file.Length == 0) continue;
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
var name = $"petugas_{Guid.NewGuid()}{ext}";
var path = Path.Combine(uploadDir, name);
await using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
fileNames.Add(name);
}
var fileUrls = fileNames.Select(n => BuildUploadUrl(dateFolder, n)).ToList();
var resolvedDraftKeyTps = ResolveDraftKey(DraftKey, SessionKey, SpjDetailId, LokasiAngkutId);
if (!string.IsNullOrWhiteSpace(resolvedDraftKeyTps))
{
var loadResult = await _detailService.LoadDraftTpsAsync(resolvedDraftKeyTps);
var draft = loadResult.Draft ?? new DraftPenjemputanNonTps { SessionKey = resolvedDraftKeyTps, SpjDetailId = SpjDetailId ?? string.Empty, LokasiAngkutId = LokasiAngkutId ?? string.Empty };
draft.FotoPetugasFileNames = fileUrls;
draft.FotoPetugasUploaded = true;
if (!string.IsNullOrWhiteSpace(SpjDetailId)) draft.SpjDetailId = SpjDetailId;
if (!string.IsNullOrWhiteSpace(LokasiAngkutId)) draft.LokasiAngkutId = LokasiAngkutId;
if (!string.IsNullOrWhiteSpace(NamaPetugas)) draft.NamaPetugas = NamaPetugas;
await _detailService.SaveDraftTpsAsync(new DraftSaveRequest
{
DraftKey = resolvedDraftKeyTps,
SessionKey = resolvedDraftKeyTps,
LokasiAngkutId = draft.LokasiAngkutId,
SpjDetailId = draft.SpjDetailId,
Latitude = draft.Latitude,
Longitude = draft.Longitude,
AlamatJalan = draft.AlamatJalan,
WaktuKedatangan = draft.WaktuKedatangan,
FotoKedatanganFileNames = draft.FotoKedatanganFileNames,
FotoKedatanganUploaded = draft.FotoKedatanganUploaded,
Timbangan = draft.Timbangan,
TotalOrganik = draft.TotalOrganik,
TotalAnorganik = draft.TotalAnorganik,
TotalResidu = draft.TotalResidu,
TotalTimbangan = draft.TotalTimbangan,
FotoPetugasFileNames = fileUrls,
FotoPetugasUploaded = true,
NamaPetugas = draft.NamaPetugas
});
}
return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." });
}
[HttpPost("ocr-timbangan")] [HttpPost("ocr-timbangan")]
[IgnoreAntiforgeryToken] [IgnoreAntiforgeryToken]
public async Task<IActionResult> OcrTimbangan(IFormFile? Foto) public async Task<IActionResult> OcrTimbangan(IFormFile? Foto)

View File

@ -1,10 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using eSPJ.Services;
namespace eSPJ.Controllers.SpjDriverUpstController namespace eSPJ.Controllers.SpjDriverUpstController
{ {
[Route("upst/history")] [Route("upst/history")]
public class HistoryController : Controller public class HistoryController : Controller
{ {
private readonly HistoryService _historyService;
public HistoryController(HistoryService historyService)
{
_historyService = historyService;
}
[HttpGet("")] [HttpGet("")]
public IActionResult Index() public IActionResult Index()
@ -12,6 +19,26 @@ namespace eSPJ.Controllers.SpjDriverUpstController
return View("~/Views/Admin/Transport/SpjDriverUpst/History/Index.cshtml"); return View("~/Views/Admin/Transport/SpjDriverUpst/History/Index.cshtml");
} }
[HttpGet("api")]
public async Task<IActionResult> GetHistory([FromQuery] string? fromDate = null, [FromQuery] string? toDate = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 5)
{
DateOnly? parsedFromDate = null;
DateOnly? parsedToDate = null;
if (!string.IsNullOrWhiteSpace(fromDate) && DateOnly.TryParse(fromDate, out var from))
{
parsedFromDate = from;
}
if (!string.IsNullOrWhiteSpace(toDate) && DateOnly.TryParse(toDate, out var to))
{
parsedToDate = to;
}
var result = await _historyService.GetUpstHistoryAsync(parsedFromDate, parsedToDate, page, pageSize <= 0 ? 5 : pageSize);
return Ok(result);
}
[HttpGet("details/{id}")] [HttpGet("details/{id}")]
public IActionResult Details(int id) public IActionResult Details(int id)
{ {

View File

@ -1 +1,38 @@
[] [
{
"Name": "TPS A",
"Index": 0,
"Latitude": "-6.481670",
"Longitude": "106.854000",
"AlamatJalan": "Cibinong, Bogor, West Java, Java, 16911, Indonesia",
"WaktuKedatangan": "16/03/2026, 09.37.44",
"FotoKedatangan": [
"kedatangan_ff132727-8942-402e-a612-ac3436e905b9.png"
],
"FotoKedatanganUploaded": true,
"Timbangan": [
{
"FotoFileName": "timbangan_0de03ef1-dbdd-4643-a0e6-ac7670b12679.png",
"Berat": [
75.23
],
"LokasiAngkut": [],
"JenisSampah": [
2
],
"IsUploaded": true,
"WaktuUpload": "2026-03-16T09:38:37.1840709+07:00"
}
],
"TotalOrganik": 0,
"TotalAnorganik": 0,
"TotalResidu": 75.23,
"TotalTimbangan": 75.23,
"FotoPetugas": [
"petugas_dffeaabf-4f30-41dd-9281-e2c33164e2c6.jpg"
],
"FotoPetugasUploaded": true,
"NamaPetugas": "usmannn",
"Submitted": true
}
]

View File

@ -0,0 +1,110 @@
[
{
"id": 1,
"noSpj": "SPJ/07-2025/PKM/000478",
"plat": "B 5678 ABC",
"kode": "JRC 007",
"tujuan": "Bantar Gebang",
"status": "In Progress",
"tanggalWaktu": "2025-07-28T16:45:00"
},
{
"id": 2,
"noSpj": "SPJ/07-2025/PKM/000476",
"plat": "B 9632 TOR",
"kode": "JRC 005",
"tujuan": "RDF Rorotan",
"status": "Completed",
"tanggalWaktu": "2025-07-27T14:30:00"
},
{
"id": 3,
"noSpj": "SPJ/07-2025/PKM/000477",
"plat": "B 1234 XYZ",
"kode": "JRC 006",
"tujuan": "RDF Pesanggarahan",
"status": "Completed",
"tanggalWaktu": "2025-07-26T09:15:00"
},
{
"id": 4,
"noSpj": "SPJ/07-2025/PKM/000479",
"plat": "B 9876 DEF",
"kode": "JRC 008",
"tujuan": "RDF Sunter",
"status": "Completed",
"tanggalWaktu": "2025-07-25T11:20:00"
},
{
"id": 5,
"noSpj": "SPJ/07-2025/PKM/000480",
"plat": "B 4321 GHI",
"kode": "JRC 009",
"tujuan": "Bantar Gebang",
"status": "Completed",
"tanggalWaktu": "2025-07-24T08:45:00"
},
{
"id": 6,
"noSpj": "SPJ/07-2025/PKM/000481",
"plat": "B 7654 JKL",
"kode": "JRC 010",
"tujuan": "RDF Marunda",
"status": "Completed",
"tanggalWaktu": "2025-07-23T10:10:00"
},
{
"id": 7,
"noSpj": "SPJ/07-2025/PKM/000482",
"plat": "B 2468 MNO",
"kode": "JRC 011",
"tujuan": "Bantar Gebang",
"status": "Completed",
"tanggalWaktu": "2025-07-22T07:25:00"
},
{
"id": 8,
"noSpj": "SPJ/07-2025/PKM/000483",
"plat": "B 1357 PQR",
"kode": "JRC 012",
"tujuan": "RDF Rorotan",
"status": "In Progress",
"tanggalWaktu": "2025-07-21T13:05:00"
},
{
"id": 9,
"noSpj": "SPJ/07-2025/PKM/000484",
"plat": "B 8080 STU",
"kode": "JRC 013",
"tujuan": "RDF Pesanggarahan",
"status": "Completed",
"tanggalWaktu": "2025-07-20T15:40:00"
},
{
"id": 10,
"noSpj": "SPJ/07-2025/PKM/000485",
"plat": "B 9090 VWX",
"kode": "JRC 014",
"tujuan": "RDF Sunter",
"status": "Completed",
"tanggalWaktu": "2025-07-19T12:00:00"
},
{
"id": 11,
"noSpj": "SPJ/07-2025/PKM/000486",
"plat": "B 2222 YZA",
"kode": "JRC 015",
"tujuan": "Bantar Gebang",
"status": "Completed",
"tanggalWaktu": "2025-07-18T06:55:00"
},
{
"id": 12,
"noSpj": "SPJ/07-2025/PKM/000487",
"plat": "B 3333 BCD",
"kode": "JRC 016",
"tujuan": "RDF Marunda",
"status": "Completed",
"tanggalWaktu": "2025-07-18T17:30:00"
}
]

View File

@ -76,4 +76,74 @@ namespace eSPJ.Models
public string? Raw { get; set; } public string? Raw { get; set; }
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
} }
public class DraftTimbanganItem
{
public decimal Berat { get; set; }
public string JenisSampah { get; set; } = "Residu";
public string FotoFileName { get; set; } = string.Empty;
public bool Uploaded { get; set; }
public string OcrInfo { get; set; } = string.Empty;
}
public class DraftPenjemputanNonTps
{
public string SessionKey { get; set; } = string.Empty;
public string LokasiAngkutId { get; set; } = string.Empty;
public string SpjDetailId { get; set; } = string.Empty;
public string Latitude { get; set; } = string.Empty;
public string Longitude { get; set; } = string.Empty;
public string AlamatJalan { get; set; } = string.Empty;
public string WaktuKedatangan { get; set; } = string.Empty;
public List<string> FotoKedatanganFileNames { get; set; } = new();
public bool FotoKedatanganUploaded { get; set; }
public List<DraftTimbanganItem> Timbangan { get; set; } = new();
public decimal TotalOrganik { get; set; }
public decimal TotalAnorganik { get; set; }
public decimal TotalResidu { get; set; }
public decimal TotalTimbangan { get; set; }
public List<string> FotoPetugasFileNames { get; set; } = new();
public bool FotoPetugasUploaded { get; set; }
public string NamaPetugas { get; set; } = string.Empty;
public bool Submitted { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.Now;
}
public class DraftSaveRequest
{
public string DraftKey { get; set; } = string.Empty;
public string SessionKey { get; set; } = string.Empty;
public string LokasiAngkutId { get; set; } = string.Empty;
public string SpjDetailId { get; set; } = string.Empty;
public string Latitude { get; set; } = string.Empty;
public string Longitude { get; set; } = string.Empty;
public string AlamatJalan { get; set; } = string.Empty;
public string WaktuKedatangan { get; set; } = string.Empty;
public bool FotoKedatanganUploaded { get; set; }
public List<string> FotoKedatanganFileNames { get; set; } = new();
public List<DraftTimbanganItem> Timbangan { get; set; } = new();
public decimal TotalOrganik { get; set; }
public decimal TotalAnorganik { get; set; }
public decimal TotalResidu { get; set; }
public decimal TotalTimbangan { get; set; }
public bool FotoPetugasUploaded { get; set; }
public List<string> FotoPetugasFileNames { get; set; } = new();
public string NamaPetugas { get; set; } = string.Empty;
}
public class DraftSaveResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string? DraftKey { get; set; }
public string? SessionKey { get; set; }
}
public class DraftLoadResponse
{
public bool Success { get; set; }
public bool HasDraft { get; set; }
public DraftPenjemputanNonTps? Draft { get; set; }
public string Message { get; set; } = string.Empty;
}
} }

View File

@ -0,0 +1,24 @@
namespace eSPJ.Models
{
public class HistoryItem
{
public int Id { get; set; }
public string NoSpj { get; set; } = string.Empty;
public string Plat { get; set; } = string.Empty;
public string Kode { get; set; } = string.Empty;
public string Tujuan { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime TanggalWaktu { get; set; }
}
public class HistoryListResponse
{
public List<HistoryItem> Items { get; set; } = new();
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalItems { get; set; }
public int TotalPages { get; set; }
public string? FromDate { get; set; }
public string? ToDate { get; set; }
}
}

View File

@ -8,6 +8,7 @@ builder.Services.AddHttpClient();
// Register custom services // Register custom services
builder.Services.AddScoped<DetailPenjemputanService>(); builder.Services.AddScoped<DetailPenjemputanService>();
builder.Services.AddScoped<HistoryService>();
var app = builder.Build(); var app = builder.Build();
@ -20,8 +21,20 @@ if (!app.Environment.IsDevelopment())
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRouting(); app.Use(async (context, next) =>
{
if (context.Request.Path.Equals("/driver/serviceworker.js", StringComparison.OrdinalIgnoreCase))
{
context.Response.OnStarting(() =>
{
context.Response.Headers["Service-Worker-Allowed"] = "/upst";
return Task.CompletedTask;
});
}
await next();
});
app.UseRouting();
app.UseAuthorization(); app.UseAuthorization();
app.MapStaticAssets(); app.MapStaticAssets();

View File

@ -114,9 +114,7 @@ namespace eSPJ.Services
}; };
} }
var uploadDateFolder = DateTime.Now.ToString("yyyy-MM-dd"); var uploadPath = Path.Combine(_env.ContentRootPath, "uploads", "penjemputan", DateTime.Now.ToString("yyyy-MM-dd"));
var uploadPath = Path.Combine(_env.WebRootPath, "uploads", "penjemputan", uploadDateFolder);
var uploadBaseUrl = $"/uploads/penjemputan/{uploadDateFolder}";
if (!Directory.Exists(uploadPath)) if (!Directory.Exists(uploadPath))
{ {
Directory.CreateDirectory(uploadPath); Directory.CreateDirectory(uploadPath);
@ -216,6 +214,130 @@ namespace eSPJ.Services
} }
} }
private string GetDraftFilePath(string prefix, string sessionKey)
{
var dir = Path.Combine(_env.ContentRootPath, "Data", "drafts");
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
var safe = string.Concat(sessionKey.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
if (string.IsNullOrEmpty(safe)) safe = "default";
return Path.Combine(dir, $"draft-{prefix}-{safe}.json");
}
private Task<DraftSaveResponse> SaveDraftAsync(string prefix, DraftSaveRequest request)
{
return SaveDraftInternalAsync(prefix, request);
}
private async Task<DraftSaveResponse> SaveDraftInternalAsync(string prefix, DraftSaveRequest request)
{
try
{
var filePath = GetDraftFilePath(prefix, request.SessionKey);
var draft = new DraftPenjemputanNonTps
{
SessionKey = request.SessionKey,
LokasiAngkutId = request.LokasiAngkutId,
SpjDetailId = request.SpjDetailId,
Latitude = request.Latitude,
Longitude = request.Longitude,
AlamatJalan = request.AlamatJalan,
WaktuKedatangan = request.WaktuKedatangan,
FotoKedatanganFileNames = request.FotoKedatanganFileNames,
FotoKedatanganUploaded = request.FotoKedatanganUploaded,
Timbangan = request.Timbangan,
TotalOrganik = request.TotalOrganik,
TotalAnorganik = request.TotalAnorganik,
TotalResidu = request.TotalResidu,
TotalTimbangan = request.TotalTimbangan,
FotoPetugasFileNames = request.FotoPetugasFileNames,
FotoPetugasUploaded = request.FotoPetugasUploaded,
NamaPetugas = request.NamaPetugas,
UpdatedAt = DateTime.Now
};
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(draft, options);
await File.WriteAllTextAsync(filePath, json);
return new DraftSaveResponse { Success = true, Message = "Draft tersimpan.", DraftKey = request.DraftKey, SessionKey = request.SessionKey };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving {Prefix} draft", prefix);
return new DraftSaveResponse { Success = false, Message = $"Gagal menyimpan draft: {ex.Message}" };
}
}
public async Task<DraftSaveResponse> SaveDraftNonTpsAsync(DraftSaveRequest request)
{
return await SaveDraftAsync("non-tps", request);
}
public async Task<DraftSaveResponse> SaveDraftTpsAsync(DraftSaveRequest request)
{
return await SaveDraftAsync("tps", request);
}
public async Task<DraftLoadResponse> LoadDraftNonTpsAsync(string sessionKey)
{
return await LoadDraftAsync("non-tps", sessionKey);
}
public async Task<DraftLoadResponse> LoadDraftTpsAsync(string sessionKey)
{
return await LoadDraftAsync("tps", sessionKey);
}
private async Task<DraftLoadResponse> LoadDraftAsync(string prefix, string sessionKey)
{
try
{
var filePath = GetDraftFilePath(prefix, sessionKey);
if (!File.Exists(filePath))
return new DraftLoadResponse { Success = true, HasDraft = false, Message = "Tidak ada draft." };
var json = await File.ReadAllTextAsync(filePath);
var draft = JsonSerializer.Deserialize<DraftPenjemputanNonTps>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (draft == null)
return new DraftLoadResponse { Success = true, HasDraft = false, Message = "Draft kosong." };
return new DraftLoadResponse { Success = true, HasDraft = true, Draft = draft };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading {Prefix} draft", prefix);
return new DraftLoadResponse { Success = false, HasDraft = false, Message = $"Gagal memuat draft: {ex.Message}" };
}
}
public async Task<bool> DeleteDraftNonTpsAsync(string sessionKey)
{
return await DeleteDraftAsync("non-tps", sessionKey);
}
public async Task<bool> DeleteDraftTpsAsync(string sessionKey)
{
return await DeleteDraftAsync("tps", sessionKey);
}
private async Task<bool> DeleteDraftAsync(string prefix, string sessionKey)
{
try
{
var filePath = GetDraftFilePath(prefix, sessionKey);
if (File.Exists(filePath)) File.Delete(filePath);
await Task.CompletedTask;
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting {Prefix} draft", prefix);
return false;
}
}
public async Task<OcrTimbanganResponse> ProcessOcrTimbanganAsync(IFormFile foto) public async Task<OcrTimbanganResponse> ProcessOcrTimbanganAsync(IFormFile foto)
{ {
try try

View File

@ -0,0 +1,83 @@
using System.Text.Json;
using eSPJ.Models;
namespace eSPJ.Services
{
public class HistoryService
{
private readonly string _historyUpstFilePath;
private readonly ILogger<HistoryService> _logger;
public HistoryService(IWebHostEnvironment env, ILogger<HistoryService> logger)
{
_logger = logger;
_historyUpstFilePath = Path.Combine(env.ContentRootPath, "Data", "history-upst.json");
}
public async Task<HistoryListResponse> GetUpstHistoryAsync(DateOnly? fromDate, DateOnly? toDate, int page = 1, int pageSize = 5)
{
page = page < 1 ? 1 : page;
pageSize = pageSize <= 0 ? 5 : pageSize;
var items = await ReadUpstHistoryAsync();
var query = items.AsEnumerable();
if (fromDate.HasValue)
{
query = query.Where(item => DateOnly.FromDateTime(item.TanggalWaktu) >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(item => DateOnly.FromDateTime(item.TanggalWaktu) <= toDate.Value);
}
var ordered = query
.OrderByDescending(item => item.TanggalWaktu)
.ToList();
var totalItems = ordered.Count;
var totalPages = totalItems == 0 ? 1 : (int)Math.Ceiling(totalItems / (double)pageSize);
var safePage = Math.Min(page, totalPages);
var pagedItems = ordered
.Skip((safePage - 1) * pageSize)
.Take(pageSize)
.ToList();
return new HistoryListResponse
{
Items = pagedItems,
Page = safePage,
PageSize = pageSize,
TotalItems = totalItems,
TotalPages = totalPages,
FromDate = fromDate?.ToString("yyyy-MM-dd"),
ToDate = toDate?.ToString("yyyy-MM-dd")
};
}
private async Task<List<HistoryItem>> ReadUpstHistoryAsync()
{
try
{
if (!File.Exists(_historyUpstFilePath))
{
return new List<HistoryItem>();
}
var json = await File.ReadAllTextAsync(_historyUpstFilePath);
var items = JsonSerializer.Deserialize<List<HistoryItem>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return items ?? new List<HistoryItem>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading UPST history JSON");
return new List<HistoryItem>();
}
}
}
}

View File

@ -3,7 +3,7 @@
ViewData["Title"] = "History - DLH"; ViewData["Title"] = "History - DLH";
} }
<div class="w-full lg:max-w-sm mx-auto bg-gray-50 min-h-screen"> <div class="w-full lg:max-w-sm mx-auto bg-gray-50 min-h-screen" id="history-page" data-history-api="@Url.Action("GetHistory", "History")" data-history-detail-base="@Url.Action("Details", "History", new { id = "__id__" })">
<div class="bg-upst text-white px-6 pt-10 pb-16 rounded-b-[40px] shadow-lg relative overflow-hidden"> <div class="bg-upst text-white px-6 pt-10 pb-16 rounded-b-[40px] shadow-lg relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-16 -mt-16"></div> <div class="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-16 -mt-16"></div>
<div class="flex items-center justify-between relative z-10"> <div class="flex items-center justify-between relative z-10">
@ -18,134 +18,58 @@
<div class="w-10"></div> <div class="w-10"></div>
</div> </div>
</div> </div>
<div class="px-5 -mt-8 space-y-4 relative z-20 pb-28">
@{ <div class="bg-white rounded-3xl p-4 border border-gray-100 shadow-sm space-y-3">
var spjList = new[] <div class="flex items-center justify-between gap-3">
{ <div>
new { <p class="text-[10px] font-black text-gray-400 uppercase tracking-widest">Filter Riwayat</p>
Id = 1, <h2 class="text-sm font-black text-gray-900">Range Tanggal Perjalanan</h2>
NoSpj = "SPJ/07-2025/PKM/000478",
Plat = "B 5678 ABC",
Kode = "JRC 007",
Tujuan = "Bantar Gebang",
Status = "In Progress",
Tanggal = "28 Jul 2025",
Waktu = "16:45"
},
new {
Id = 2,
NoSpj = "SPJ/07-2025/PKM/000476",
Plat = "B 9632 TOR",
Kode = "JRC 005",
Tujuan = "RDF Rorotan",
Status = "Completed",
Tanggal = "27 Jul 2025",
Waktu = "14:30"
},
new {
Id = 3,
NoSpj = "SPJ/07-2025/PKM/000477",
Plat = "B 1234 XYZ",
Kode = "JRC 006",
Tujuan = "RDF Pesanggarahan",
Status = "Completed",
Tanggal = "26 Jul 2025",
Waktu = "09:15"
},
new {
Id = 4,
NoSpj = "SPJ/07-2025/PKM/000479",
Plat = "B 9876 DEF",
Kode = "JRC 008",
Tujuan = "RDF Sunter",
Status = "Completed",
Tanggal = "25 Jul 2025",
Waktu = "11:20"
},
new {
Id = 5,
NoSpj = "SPJ/07-2025/PKM/000480",
Plat = "B 4321 GHI",
Kode = "JRC 009",
Tujuan = "Bantar Gebang",
Status = "Completed",
Tanggal = "24 Jul 2025",
Waktu = "08:45"
}
};
}
<div class="px-5 -mt-8 space-y-5 relative z-20">
@foreach (var spj in spjList)
{
<a href="@Url.Action("Details", "History", new { id = spj.Id })" class="block group">
<div class="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 group-hover:shadow-md group-hover:-translate-y-1 transition-all duration-300">
<div class="flex justify-between items-start mb-4">
<div class="space-y-0.5">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Nomor Dokumen</p>
<p class="text-sm font-black text-gray-800">@spj.NoSpj</p>
</div>
@if (spj.Status == "Completed")
{
<span class="bg-green-50 text-green-600 px-3 py-1 rounded-lg text-[10px] font-black uppercase border border-green-100">
Selesai
</span>
}
else
{
<span class="bg-blue-50 text-blue-600 px-3 py-1 rounded-lg text-[10px] font-black uppercase border border-blue-100 animate-pulse">
Proses
</span>
}
</div>
<div class="flex items-center gap-4 bg-gray-50/80 p-3 rounded-2xl border border-dashed border-gray-200">
<div class="w-12 h-12 bg-upst rounded-xl flex items-center justify-center shadow-inner">
<i class="w-6 h-6 text-white" data-lucide="truck"></i>
</div>
<div class="flex-1">
<div class="flex justify-between items-baseline">
<h2 class="font-black text-gray-900 leading-tight">@spj.Plat</h2>
<span class="text-[11px] font-bold text-upst">@spj.Waktu</span>
</div>
<div class="flex justify-between items-center">
<span class="text-xs text-gray-500 font-medium">@spj.Kode</span>
<span class="text-[10px] text-gray-400 font-semibold">@spj.Tanggal</span>
</div>
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-upst/40"></div>
<div class="flex flex-col">
<span class="text-[10px] text-gray-400 font-bold uppercase">Tujuan Akhir</span>
<span class="text-sm font-bold text-gray-700">@spj.Tujuan</span>
</div>
</div>
<div class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-upst transition-colors">
<i class="w-4 h-4" data-lucide="chevron-right"></i>
</div>
</div>
</div> </div>
</a> <button id="history-reset-filter" type="button" class="text-[11px] font-bold text-upst px-3 py-2 rounded-xl bg-orange-50 border border-orange-100">Reset</button>
} </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input id="history-from-date-filter" type="date" class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 bg-gray-50" />
<input id="history-to-date-filter" type="date" class="w-full rounded-2xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 bg-gray-50" />
</div>
<div class="flex items-center justify-end">
<button id="history-apply-filter" type="button" class="rounded-2xl bg-upst text-white px-4 py-3 text-xs font-black uppercase tracking-wide">Terapkan</button>
</div>
</div>
<div id="history-loading" class="bg-white rounded-3xl p-5 border border-gray-100 text-center text-sm font-medium text-gray-500 shadow-sm">
Memuat riwayat perjalanan...
</div>
<div id="history-empty" class="hidden bg-white rounded-3xl p-6 border border-gray-100 shadow-sm text-center">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="w-8 h-8 text-gray-400" data-lucide="clock-3"></i>
</div>
<h3 class="text-base font-black text-gray-900 mb-1">Belum Ada Riwayat</h3>
<p class="text-sm text-gray-500">Coba ubah filter tanggal atau tunggu data perjalanan masuk.</p>
</div>
<div id="history-list" class="space-y-5"></div>
<div id="history-pagination" class="hidden bg-white rounded-3xl p-4 border border-gray-100 shadow-sm space-y-3">
<div class="flex items-center justify-between text-[11px] font-bold text-gray-500 uppercase tracking-wide">
<span id="history-page-info">Page 1</span>
<span id="history-total-info">0 data</span>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<button id="history-prev-page" type="button" class="sm:flex-1 rounded-2xl border border-gray-200 px-4 py-3 text-xs font-black text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">Sebelumnya</button>
<div class="max-w-full overflow-x-auto">
<div id="history-page-buttons" class="flex items-center gap-2 min-w-max"></div>
</div>
<button id="history-next-page" type="button" class="sm:flex-1 rounded-2xl border border-gray-200 px-4 py-3 text-xs font-black text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">Berikutnya</button>
</div>
</div>
</div> </div>
<partial name="~/Views/Admin/Transport/SpjDriverUpst/Shared/Components/_Navigation.cshtml" /> <partial name="~/Views/Admin/Transport/SpjDriverUpst/Shared/Components/_Navigation.cshtml" />
<!-- Kalau butuh tampilan kosong (jika tidak ada data) -->
@* <div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<i class="w-12 h-12 text-gray-400" data-lucide="clock"></i>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Belum Ada Riwayat</h3>
<p class="text-gray-500 text-center text-sm">Riwayat perjalanan Anda akan muncul di sini setelah melakukan perjalanan pertama.</p>
</div> *@
</div> </div>
@section Scripts {
<script src="@Url.Content("~/driver/js/history-upst.js")" asp-append-version="true"></script>
}

View File

@ -33,80 +33,95 @@
</div> </div>
</div> </div>
<div class="px-4 -mt-16 relative z-20"> <div class="px-4 -mt-16 relative z-20 flex flex-col gap-6">
<div class="bg-upst-light rounded-3xl p-4 border border-gray-100 mb-6">
<div class="flex items-center gap-4 group cursor-pointer" id="userLocationBtn"> <div class="bg-upst-light rounded-[28px] p-4 border border-gray-100 shadow-sm">
<div class="w-10 h-10 bg-upst rounded-2xl flex items-center justify-center flex-shrink-0 group-active:scale-90 transition-transform"> <div class="flex items-center gap-4 group cursor-pointer" id="userLocationBtn">
<i class="w-5 h-5 text-white" data-lucide="map-pin"></i> <div class="w-12 h-12 bg-upst rounded-2xl flex items-center justify-center shrink-0 shadow-md group-active:scale-90 transition-transform">
</div> <i class="w-6 h-6 text-white" data-lucide="map-pin"></i>
<div class="flex-1 min-w-0">
<p class="text-[9px] font-black text-gray-400 uppercase tracking-wider leading-none mb-1">Lokasi Saat Ini</p>
<p id="userLocation" class="text-xs line-clamp-3 font-bold text-gray-700 italic">Mendeteksi lokasi...</p>
</div>
<i class="w-4 h-4 text-gray-700" data-lucide="refresh-cw"></i>
</div> </div>
</div> <div class="flex-1 min-w-0">
<p class="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1">Lokasi Saat Ini</p>
<div class="grid grid-cols-2 gap-2 mb-4 p-1 bg-white rounded-2xl border border-gray-100 shadow-sm"> <p id="userLocation" class="text-sm font-bold text-gray-800 leading-tight line-clamp-2">Mendeteksi lokasi...</p>
<button id="tabSpjButton" type="button" class="px-3 py-2 text-xs font-black rounded-xl bg-upst text-white transition-all">
SPJ
</button>
<button id="tabMapsButton" type="button" class="px-3 py-2 text-xs font-black rounded-xl text-gray-500 bg-gray-100 transition-all">
MAPS
</button>
</div>
<div id="spjCardPanel" class="bg-upst rounded-[35px] overflow-hidden shadow-2xl relative h-[300px]">
<div class="bg-white/10 backdrop-blur-md p-5 flex justify-between items-center border-b border-white/10">
<div>
<p class="text-[10px] font-black text-green-100 uppercase tracking-widest">Nomor SPJ</p>
<p class="text-white font-mono font-bold text-sm">SPJ/07-2025/PKM/000476</p>
</div>
<div class="text-right">
<span class="bg-white text-green-600 text-[10px] font-black px-3 py-1 rounded-lg shadow-sm">B 9632 TOR</span>
</div>
</div> </div>
<div class="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm border border-gray-100 shrink-0 group-active:rotate-180 transition-transform duration-500">
<div class="p-6 relative h-[236px]"> <i class="w-4 h-4 text-gray-500" data-lucide="refresh-cw"></i>
<div class="flex justify-between items-center h-full">
<div class="space-y-4">
<div>
<p class="text-[10px] text-green-100 font-bold uppercase opacity-80">Tujuan Pembuangan</p>
<h2 class="text-2xl font-black text-white tracking-tight leading-tight">JRC Rorotan</h2>
<p class="text-xs text-green-100/70 font-medium">(JRC 005)</p>
</div>
</div>
<button id="qrCodeTrigger" class="w-16 h-16 bg-white rounded-[24px] flex items-center justify-center shadow-2xl border-4 border-green-600/20 active:scale-90 transition-transform">
<img src="@Url.Content("~/driver/images/qr.png")" alt="QR" class="w-10 h-10 object-contain">
</button>
</div>
<img src="@Url.Content("~/driver/tree.svg")" class="absolute -left-4 -bottom-4 w-20 h-20 opacity-10 pointer-events-none" alt="">
</div>
</div>
<div id="mapsCardPanel" class="bg-upst rounded-[35px] overflow-hidden shadow-2xl relative h-[300px] flex-col hidden">
<div class="bg-white/10 backdrop-blur-md p-5 flex justify-between items-center border-b border-white/10">
<div>
<p class="text-[10px] font-black text-green-100 uppercase tracking-widest">Maps</p>
<p class="text-white font-mono font-bold text-sm">Rute Pengangkutan</p>
</div>
<div class="text-right">
<span class="bg-white text-green-600 text-[10px] font-black px-3 py-1 rounded-lg shadow-sm">LIVE</span>
</div>
</div>
<div class="p-6 relative h-[236px]">
<div id="pickupMap" class="h-full w-full z-10 rounded-3xl overflow-hidden border border-white/20"></div>
<button id="recenterMapButton" type="button" class="absolute top-9 right-9 z-20 w-11 h-11 bg-white/95 text-upst rounded-2xl shadow-xl border border-white/70 backdrop-blur flex items-center justify-center active:scale-95 transition-transform" title="Tampilkan semua lokasi">
<i class="w-5 h-5" data-lucide="locate-fixed"></i>
</button>
<img src="@Url.Content("~/driver/tree.svg")" class="absolute -left-4 -bottom-4 w-20 h-20 opacity-10 pointer-events-none" alt="">
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4">
<div class="bg-white p-1.5 rounded-2xl border border-gray-100 shadow-sm flex">
<button id="tabSpjButton" type="button" class="flex-1 py-3 text-xs font-black rounded-xl bg-upst text-white shadow-md transition-all">
DETAIL SPJ
</button>
<button id="tabMapsButton" type="button" class="flex-1 py-3 text-xs font-black rounded-xl text-gray-500 bg-transparent hover:bg-gray-50 transition-all">
LIVE MAPS
</button>
</div>
<div class="relative">
<div id="spjCardPanel" class="bg-upst rounded-[32px] overflow-hidden shadow-xl flex flex-col h-[340px]">
<div class="px-6 py-5 bg-black/10 border-b border-white/5 flex justify-between items-center shrink-0">
<div>
<p class="text-[10px] font-black text-green-100 uppercase tracking-widest mb-1">Nomor SPJ</p>
<p class="text-white font-mono font-bold text-sm">SPJ/07-2025/PKM/000476</p>
</div>
<div class="bg-white px-3 py-1.5 rounded-xl shadow-sm">
<span class="text-green-600 text-xs font-black">B 9632 TOR</span>
</div>
</div>
<div class="p-6 flex-1 flex flex-col justify-center relative overflow-hidden">
<img src="@Url.Content("~/driver/tree.svg")" class="absolute -right-6 -bottom-6 w-40 h-40 opacity-10 pointer-events-none" alt="">
<div class="z-10 flex flex-row justify-between items-center w-full mt-2">
<div class="flex-1 pr-4">
<p class="text-[10px] text-green-100 font-bold uppercase tracking-wider mb-2">Tujuan Pembuangan</p>
<h2 class="text-3xl font-black text-white leading-tight mb-2">JRC Rorotan</h2>
<span class="bg-white/10 text-green-50 text-xs font-bold px-3 py-1 rounded-lg border border-white/10 inline-block">
JRC 005
</span>
</div>
<button id="qrCodeTrigger" class="bg-white p-4 rounded-3xl shadow-xl border-4 border-white/20 active:scale-90 transition-transform group hover:border-green-400 shrink-0">
<img src="@Url.Content("~/driver/images/qr.png")" alt="QR" class="w-10 h-10 object-contain group-hover:scale-110 transition-transform">
</button>
</div>
</div>
</div>
<div id="mapsCardPanel" class="bg-upst rounded-[32px] overflow-hidden shadow-xl hidden flex-col h-[340px]">
<div class="px-6 py-5 bg-black/10 flex justify-between items-center shrink-0 z-20">
<div>
<p class="text-[10px] font-black text-green-100 uppercase tracking-widest mb-1">Rute Pengangkutan</p>
<p class="text-white font-bold text-sm">Live Location</p>
</div>
<div class="bg-white/10 border border-white/20 px-3 py-1.5 rounded-xl shadow-sm flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-red-400 animate-pulse"></span>
<span class="text-white text-[10px] font-black tracking-wider">LIVE</span>
</div>
</div>
<div class="relative flex-1 w-full bg-gray-100">
<div id="pickupMap" class="absolute inset-0 w-full h-full z-10"></div>
<button id="recenterMapButton" type="button" class="absolute bottom-5 right-5 z-20 w-12 h-12 bg-white text-upst rounded-2xl shadow-lg border border-gray-200 flex items-center justify-center active:scale-90 transition-transform">
<i class="w-6 h-6" data-lucide="locate-fixed"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="px-6 mt-10"> <div class="px-6 mt-10">
<div class="flex justify-between items-end mb-6"> <div class="flex justify-between items-end mb-6">
<div> <div>
@ -300,7 +315,6 @@ document.addEventListener("DOMContentLoaded", function () {
getLocationUpdate(); getLocationUpdate();
} }
// Update Lokasi cuy
userLocationEl.addEventListener("click", function () { userLocationEl.addEventListener("click", function () {
getLocationUpdate(); getLocationUpdate();
}); });
@ -358,30 +372,131 @@ document.addEventListener("DOMContentLoaded", function () {
let mapInstance = null; let mapInstance = null;
let mapBounds = null; let mapBounds = null;
let mapInitialized = false; let mapInitialized = false;
let userMarker = null;
let liveLocationWatchId = null;
let lastUserLatLng = null;
let lastKnownHeading = 0;
function fitAllLocations() { function fitAllLocations() {
if (!mapInstance || !mapBounds) return; if (!mapInstance || !mapBounds) return;
mapInstance.invalidateSize(); mapInstance.invalidateSize();
if (Array.isArray(mapBounds) && mapBounds.length === 1) { if (Array.isArray(mapBounds) && mapBounds.length === 1) {
mapInstance.setView(mapBounds[0], 16); mapInstance.setView(mapBounds[0], 16);
return; return;
} }
mapInstance.fitBounds(mapBounds, { padding: [24, 24], maxZoom: 17 });
}
mapInstance.fitBounds(mapBounds, { function toRadians(deg) {
padding: [24, 24], return deg * Math.PI / 180;
maxZoom: 17 }
function toDegrees(rad) {
return rad * 180 / Math.PI;
}
function getBearing(from, to) {
const [lat1, lng1] = from;
const [lat2, lng2] = to;
const phi1 = toRadians(lat1);
const phi2 = toRadians(lat2);
const lambda1 = toRadians(lng1);
const lambda2 = toRadians(lng2);
const y = Math.sin(lambda2 - lambda1) * Math.cos(phi2);
const x =
Math.cos(phi1) * Math.sin(phi2) -
Math.sin(phi1) * Math.cos(phi2) * Math.cos(lambda2 - lambda1);
return (toDegrees(Math.atan2(y, x)) + 360) % 360;
}
function createTruckDivIcon(rotationDeg) {
const carIconHtml = `
<div class="w-10 h-10 flex items-center justify-center">
<img
src="@Url.Content("~/driver/images/truck_icon.png")"
alt="Truck"
class="w-10 h-10 object-contain drop-shadow-lg"
style="transform: rotate(${rotationDeg}deg); transition: transform 0.25s ease;"
/>
</div>`;
return L.divIcon({
className: 'bg-transparent border-0',
html: carIconHtml,
iconSize: [40, 40],
iconAnchor: [20, 20],
popupAnchor: [0, -15]
}); });
} }
function handleUserPosition(position) {
if (!mapInstance) return;
const lat = position.coords.latitude;
const lng = position.coords.longitude;
const nextLatLng = [lat, lng];
let heading = Number.isFinite(position.coords.heading)
? position.coords.heading
: null;
if (!Number.isFinite(heading) && lastUserLatLng) {
heading = getBearing(lastUserLatLng, nextLatLng);
}
if (Number.isFinite(heading)) {
lastKnownHeading = heading;
}
const rotationDeg = (lastKnownHeading + 180) % 360;
const truckIcon = createTruckDivIcon(rotationDeg);
if (userMarker) {
userMarker.setLatLng(nextLatLng);
userMarker.setIcon(truckIcon);
} else {
userMarker = L.marker(nextLatLng, { icon: truckIcon, zIndexOffset: 1000 })
.addTo(mapInstance)
.bindPopup("<div class='text-xs font-bold text-center text-blue-600 mb-1'>Posisi Anda</div><div class='text-[10px] text-gray-500 text-center'>Diperbarui otomatis via GPS</div>");
}
lastUserLatLng = nextLatLng;
}
function updateUserLocationOnMap() {
if (!mapInstance || !("geolocation" in navigator)) return;
navigator.geolocation.getCurrentPosition(
handleUserPosition,
function (error) {
console.warn("Gagal mengambil GPS untuk live tracking:", error);
},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 15000 }
);
}
function startLiveTracking() {
if (!mapInstance || !("geolocation" in navigator) || liveLocationWatchId !== null) return;
liveLocationWatchId = navigator.geolocation.watchPosition(
handleUserPosition,
function (error) {
console.warn("Gagal mengambil GPS untuk live tracking:", error);
},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 15000 }
);
}
function ensureLeaflet() { function ensureLeaflet() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (window.L && typeof window.L.map === "function") { if (window.L && typeof window.L.map === "function") {
resolve(); resolve();
return; return;
} }
if (!document.getElementById("leaflet-css")) { if (!document.getElementById("leaflet-css")) {
const css = document.createElement("link"); const css = document.createElement("link");
css.id = "leaflet-css"; css.id = "leaflet-css";
@ -389,14 +504,12 @@ document.addEventListener("DOMContentLoaded", function () {
css.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; css.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(css); document.head.appendChild(css);
} }
const existingScript = document.getElementById("leaflet-js"); const existingScript = document.getElementById("leaflet-js");
if (existingScript) { if (existingScript) {
existingScript.addEventListener("load", resolve, { once: true }); existingScript.addEventListener("load", resolve, { once: true });
existingScript.addEventListener("error", reject, { once: true }); existingScript.addEventListener("error", reject, { once: true });
return; return;
} }
const script = document.createElement("script"); const script = document.createElement("script");
script.id = "leaflet-js"; script.id = "leaflet-js";
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
@ -418,7 +531,6 @@ document.addEventListener("DOMContentLoaded", function () {
function normalizeLatLng(item) { function normalizeLatLng(item) {
const lat = toNumber(item.latitude); const lat = toNumber(item.latitude);
const lng = toNumber(item.longitude); const lng = toNumber(item.longitude);
if (isValidLatLng(lat, lng)) return { lat, lng }; if (isValidLatLng(lat, lng)) return { lat, lng };
if (isValidLatLng(lng, lat)) return { lat: lng, lng: lat }; if (isValidLatLng(lng, lat)) return { lat: lng, lng: lat };
return null; return null;
@ -446,23 +558,15 @@ document.addEventListener("DOMContentLoaded", function () {
async function getRoadRouteLatLngs(latLngs) { async function getRoadRouteLatLngs(latLngs) {
if (!Array.isArray(latLngs) || latLngs.length < 2) return null; if (!Array.isArray(latLngs) || latLngs.length < 2) return null;
try { try {
const coordString = latLngs const coordString = latLngs.map(([lat, lng]) => `${lng},${lat}`).join(";");
.map(([lat, lng]) => `${lng},${lat}`)
.join(";");
const url = `https://router.project-osrm.org/route/v1/driving/${coordString}?overview=full&geometries=geojson&steps=false&alternatives=false`; const url = `https://router.project-osrm.org/route/v1/driving/${coordString}?overview=full&geometries=geojson&steps=false&alternatives=false`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
const coords = data?.routes?.[0]?.geometry?.coordinates; const coords = data?.routes?.[0]?.geometry?.coordinates;
if (!Array.isArray(coords) || coords.length < 2) return null; if (!Array.isArray(coords) || coords.length < 2) return null;
return coords.map(pair => [toNumber(pair?.[1]), toNumber(pair?.[0])]).filter(([lat, lng]) => isValidLatLng(lat, lng));
return coords
.map(pair => [toNumber(pair?.[1]), toNumber(pair?.[0])])
.filter(([lat, lng]) => isValidLatLng(lat, lng));
} catch { } catch {
return null; return null;
} }
@ -471,13 +575,12 @@ document.addEventListener("DOMContentLoaded", function () {
async function initMap() { async function initMap() {
try { try {
if (mapInitialized && mapInstance) { if (mapInitialized && mapInstance) {
setTimeout(() => { setTimeout(() => fitAllLocations(), 80);
fitAllLocations();
}, 80);
return; return;
} }
await ensureLeaflet(); await ensureLeaflet();
const res = await fetch("@Url.Content("~/driver/json/pengangkutan.json")"); const res = await fetch("@Url.Content("~/driver/json/pengangkutan.json")");
const payload = await res.json(); const payload = await res.json();
const rows = Array.isArray(payload?.data) ? payload.data : []; const rows = Array.isArray(payload?.data) ? payload.data : [];
@ -513,13 +616,25 @@ document.addEventListener("DOMContentLoaded", function () {
const latLngs = points.map(p => [p.lat, p.lng]); const latLngs = points.map(p => [p.lat, p.lng]);
points.forEach((point, index) => { points.forEach((point, index) => {
const popupContent = `
<div class="font-sans min-w-[150px]">
<b class="block text-sm text-gray-800 mb-1">${index + 1}. ${point.name || "Lokasi"}</b>
<span class="text-xs text-gray-500 block mb-3 line-clamp-2">${point.alamat || "-"}</span>
<a href="https://www.google.com/maps/dir/?api=1&destination=${point.lat},${point.lng}"
target="_blank"
class="bg-blue-600 !text-white w-full py-2 rounded-xl text-[11px] font-bold flex items-center justify-center shadow-md hover:bg-blue-700 active:scale-95 transition-all"
style="text-decoration:none;">
<svg class="w-3 h-3 mr-1.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.242-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg> BUKA DI GMAPS
</a>
</div>
`;
L.marker([point.lat, point.lng], { icon: pickupIcon }) L.marker([point.lat, point.lng], { icon: pickupIcon })
.addTo(mapInstance) .addTo(mapInstance)
.bindPopup(`<b>${index + 1}. ${point.name || "Lokasi"}</b><br>${point.alamat || "-"}`); .bindPopup(popupContent);
}); });
let routeLatLngs = latLngs; let routeLatLngs = latLngs;
if (latLngs.length > 1) { if (latLngs.length > 1) {
const routed = await getRoadRouteLatLngs(latLngs); const routed = await getRoadRouteLatLngs(latLngs);
if (routed && routed.length > 1) { if (routed && routed.length > 1) {
@ -537,9 +652,9 @@ document.addEventListener("DOMContentLoaded", function () {
fitAllLocations(); fitAllLocations();
mapInitialized = true; mapInitialized = true;
setTimeout(() => { updateUserLocationOnMap();
fitAllLocations(); startLiveTracking();
}, 80);
} catch (err) { } catch (err) {
mapEl.innerHTML = '<div class="h-full flex items-center justify-center text-xs font-semibold text-red-500">Gagal memuat peta</div>'; mapEl.innerHTML = '<div class="h-full flex items-center justify-center text-xs font-semibold text-red-500">Gagal memuat peta</div>';
console.error("Map init error:", err); console.error("Map init error:", err);
@ -563,9 +678,17 @@ document.addEventListener("DOMContentLoaded", function () {
if (mapsCardPanel && !mapsCardPanel.classList.contains("hidden")) { if (mapsCardPanel && !mapsCardPanel.classList.contains("hidden")) {
initMap(); initMap();
} }
window.addEventListener("beforeunload", function () {
if (liveLocationWatchId !== null && "geolocation" in navigator) {
navigator.geolocation.clearWatch(liveLocationWatchId);
liveLocationWatchId = null;
}
});
}); });
</script> </script>
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const btn = document.getElementById("profileMenuButton"); const btn = document.getElementById("profileMenuButton");

View File

@ -529,7 +529,7 @@
// PWA Service Worker Registration // PWA Service Worker Registration
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/driver/sw.js') navigator.serviceWorker.register('/serviceworker.js', { scope: '/' })
.then((registration) => { .then((registration) => {
console.log('SW registered: ', registration); console.log('SW registered: ', registration);

View File

@ -0,0 +1,44 @@
<div id="pwaInstallPrompt" class="fixed inset-x-4 bottom-28 z-120 hidden">
<div class="mx-auto w-full max-w-xs rounded-[28px] bg-upst-light px-4 py-4 text-gray-500 shadow-2xl ring-1 ring-black/5">
<div class="flex items-start gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-upst text-xl font-black text-white">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tablet-smartphone-icon lucide-tablet-smartphone"><rect width="10" height="14" x="3" y="8" rx="2"/><path d="M5 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-2.4"/><path d="M8 18h.01"/></svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-[10px] font-black uppercase tracking-[0.22em] text-gray-400">Install App</p>
<h3 class="mt-1 text-sm font-black tracking-tight">Pasang eSPJ di perangkat</h3>
<p id="pwaInstallDescription" class="mt-1 text-[11px] leading-relaxed text-gray-500/80">Akses lebih cepat langsung dari home screen tanpa buka browser dulu.</p>
</div>
<button id="pwaInstallDismiss" type="button" class="flex h-9 w-9 items-center justify-center rounded-xl bg-white/10 text-lg font-bold text-gray-500/80 transition active:scale-90">×</button>
</div>
<div class="mt-4 flex gap-2">
<button id="pwaInstallButton" type="button" class="flex-1 rounded-2xl bg-upst text-white px-4 py-3 text-xs font-black uppercase tracking-wide text-upst shadow-sm transition active:scale-95">
Install Sekarang
</button>
<button id="pwaInstallHelpButton" type="button" class="rounded-2xl border border-gray-200 bg-gray-100 px-4 py-3 text-xs font-black uppercase tracking-wide text-gray-500 transition active:scale-95">
Bantuan
</button>
</div>
</div>
</div>
<div id="pwaInstallHelpModal" class="fixed inset-0 z-130 hidden items-end justify-center bg-slate-950/60 p-4 backdrop-blur-sm sm:items-center">
<div class="w-full max-w-sm rounded-4xl bg-white p-6 shadow-2xl">
<p class="text-[10px] font-black uppercase tracking-[0.24em] text-upst">Cara Install</p>
<h3 class="mt-2 text-lg font-black tracking-tight text-slate-900">Pasang eSPJ ke home screen</h3>
<p id="pwaInstallHelpText" class="mt-3 text-sm leading-relaxed text-slate-600">Gunakan menu browser lalu pilih install aplikasi.</p>
<div id="pwaInstallHelpSteps" class="mt-5 grid gap-2 rounded-2xl bg-slate-50 p-4 text-xs leading-relaxed text-slate-600">
<p>Chrome / Edge: buka menu browser lalu pilih <span class="font-bold text-slate-800">Install app</span> atau <span class="font-bold text-slate-800">Add to Home screen</span>.</p>
<p>Firefox Android: buka menu browser lalu pilih <span class="font-bold text-slate-800">Install</span> atau <span class="font-bold text-slate-800">Add to Home screen</span> jika tersedia.</p>
<p>iPhone / iPad: tekan <span class="font-bold text-slate-800">Share</span> lalu pilih <span class="font-bold text-slate-800">Add to Home Screen</span>.</p>
<p>Firefox desktop: browser ini belum mendukung install PWA berbasis manifest dari web.</p>
</div>
<div class="mt-6 flex gap-2">
<button id="pwaInstallHelpClose" type="button" class="flex-1 rounded-2xl bg-slate-100 px-4 py-3 text-xs font-black uppercase tracking-wide text-slate-700 transition active:scale-95">Tutup</button>
<button id="pwaInstallHelpPrimary" type="button" class="flex-1 rounded-2xl bg-upst px-4 py-3 text-xs font-black uppercase tracking-wide text-gray-500 shadow-sm transition active:scale-95">Install</button>
</div>
</div>
</div>

View File

@ -0,0 +1,200 @@
<script src="@Url.Content("~/driver/lib/jquery/dist/jquery.min.js")"></script>
<script src="@Url.Content("~/driver/lib/bootstrap/dist/js/bootstrap.bundle.min.js")"></script>
<script src="@Url.Content("~/driver/js/site.js")" asp-append-version="true"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
lucide.createIcons();
});
</script>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("@Url.Content("~/driver/serviceworker.js")", { scope: "/upst" });
});
}
</script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const installPrompt = document.getElementById("pwaInstallPrompt");
const installButton = document.getElementById("pwaInstallButton");
const dismissButton = document.getElementById("pwaInstallDismiss");
const helpButton = document.getElementById("pwaInstallHelpButton");
const helpModal = document.getElementById("pwaInstallHelpModal");
const helpClose = document.getElementById("pwaInstallHelpClose");
const helpPrimary = document.getElementById("pwaInstallHelpPrimary");
const helpText = document.getElementById("pwaInstallHelpText");
const helpSteps = document.getElementById("pwaInstallHelpSteps");
const description = document.getElementById("pwaInstallDescription");
if (!installPrompt || !installButton || !dismissButton || !helpButton || !helpModal || !helpClose || !helpPrimary || !helpText || !helpSteps || !description) {
return;
}
const dismissKey = "espj-upst-pwa-install-dismissed-session";
let deferredPrompt = null;
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isIos() {
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
}
function isAndroid() {
return /android/i.test(window.navigator.userAgent);
}
function isFirefox() {
return /firefox|fxios/i.test(window.navigator.userAgent);
}
function isDesktopFirefox() {
return isFirefox() && !isAndroid() && !isIos();
}
function showInstallPrompt() {
if (isStandalone()) return;
installPrompt.classList.remove("hidden");
}
function hideInstallPrompt(rememberDismissal) {
installPrompt.classList.add("hidden");
if (rememberDismissal) {
window.sessionStorage.setItem(dismissKey, "true");
}
}
function openHelpModal() {
helpModal.classList.remove("hidden");
helpModal.classList.add("flex");
}
function closeHelpModal() {
helpModal.classList.add("hidden");
helpModal.classList.remove("flex");
}
function updateManualInstallCopy() {
if (isIos()) {
description.textContent = "Di iPhone/iPad, install dilakukan lewat menu Share browser, termasuk di Firefox iOS.";
helpText.textContent = "Tekan tombol Share di browser, lalu pilih Add to Home Screen untuk memasang eSPJ.";
helpSteps.innerHTML = `
<p>Safari / Chrome / Edge / Firefox di iPhone & iPad: tekan <span class="font-bold text-slate-800">Share</span>, lalu pilih <span class="font-bold text-slate-800">Add to Home Screen</span>.</p>
<p>Kalau opsi belum terlihat, scroll menu Share ke bawah sampai bagian tindakan tambahan.</p>
`;
helpPrimary.textContent = "Lihat Panduan";
installButton.textContent = "Buka Panduan";
return;
}
if (isDesktopFirefox()) {
description.textContent = "Firefox desktop belum mendukung install PWA berbasis manifest langsung dari web.";
helpText.textContent = "eSPJ tetap bisa dipakai normal di Firefox desktop, tapi untuk install sebagai app gunakan Chrome, Edge, atau Safari macOS yang mendukung.";
helpSteps.innerHTML = `
<p>Firefox desktop: belum ada dukungan install PWA via manifest file.</p>
<p>Jika ingin pengalaman app di desktop, buka eSPJ di <span class="font-bold text-slate-800">Chrome</span> atau <span class="font-bold text-slate-800">Edge</span>, lalu pilih <span class="font-bold text-slate-800">Install app</span>.</p>
`;
helpPrimary.textContent = "Mengerti";
installButton.textContent = "Info Firefox";
return;
}
if (isFirefox() && isAndroid() && !deferredPrompt) {
description.textContent = "Firefox Android biasanya install lewat menu browser, bukan popup native seperti Chrome.";
helpText.textContent = "Buka menu tiga titik Firefox lalu pilih Install atau Add to Home screen jika tersedia.";
helpSteps.innerHTML = `
<p>Firefox Android: buka menu <span class="font-bold text-slate-800">⋮</span> lalu cari <span class="font-bold text-slate-800">Install</span> atau <span class="font-bold text-slate-800">Add to Home screen</span>.</p>
<p>Kalau opsi belum muncul, refresh `/upst` sekali setelah service worker aktif lalu cek lagi.</p>
`;
helpPrimary.textContent = "Lihat Panduan";
installButton.textContent = "Cara Install";
return;
}
description.textContent = deferredPrompt
? "Install app untuk pengalaman lebih cepat dan nyaman dari browser."
: "Kalau popup install belum muncul, tekan tombol ini lalu ikuti panduan. Kadang perlu refresh sekali setelah service worker aktif.";
helpText.textContent = deferredPrompt
? "Tekan Install untuk membuka dialog install native dari browser."
: "Gunakan menu browser lalu pilih Install app atau Add to Home screen. Jika ini pertama kali buka `/upst`, refresh sekali lalu coba lagi.";
helpSteps.innerHTML = `
<p>Chrome / Edge: buka menu browser lalu pilih <span class="font-bold text-slate-800">Install app</span> atau <span class="font-bold text-slate-800">Add to Home screen</span>.</p>
<p>Firefox Android: buka menu browser lalu pilih <span class="font-bold text-slate-800">Install</span> atau <span class="font-bold text-slate-800">Add to Home screen</span> jika tersedia.</p>
<p>Firefox desktop: belum mendukung install PWA berbasis manifest dari web.</p>
`;
helpPrimary.textContent = deferredPrompt ? "Install" : "Buka Panduan";
installButton.textContent = deferredPrompt ? "Install Sekarang" : "Cara Install";
}
const isDismissedThisSession = window.sessionStorage.getItem(dismissKey) === "true";
updateManualInstallCopy();
if (!isStandalone() && !isDismissedThisSession) {
showInstallPrompt();
}
window.addEventListener("beforeinstallprompt", function (event) {
event.preventDefault();
deferredPrompt = event;
updateManualInstallCopy();
if (!isStandalone()) {
showInstallPrompt();
}
});
window.addEventListener("appinstalled", function () {
deferredPrompt = null;
hideInstallPrompt(false);
closeHelpModal();
});
dismissButton.addEventListener("click", function () {
hideInstallPrompt(true);
});
helpButton.addEventListener("click", function () {
updateManualInstallCopy();
openHelpModal();
});
helpClose.addEventListener("click", closeHelpModal);
helpModal.addEventListener("click", function (event) {
if (event.target === helpModal) {
closeHelpModal();
}
});
async function triggerInstall() {
updateManualInstallCopy();
if (!deferredPrompt) {
openHelpModal();
return;
}
deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome !== "accepted") {
showInstallPrompt();
}
deferredPrompt = null;
updateManualInstallCopy();
}
installButton.addEventListener("click", triggerInstall);
helpPrimary.addEventListener("click", function () {
if (deferredPrompt) {
closeHelpModal();
triggerInstall();
return;
}
closeHelpModal();
});
});
</script>

View File

@ -7,8 +7,8 @@
<script type="importmap"></script> <script type="importmap"></script>
<!-- Theme Colors --> <!-- Theme Colors -->
<meta name="theme-color" content="#f97316"> <meta name="theme-color" content="#fb923c">
<meta name="msapplication-navbutton-color" content="#f97316"> <meta name="msapplication-navbutton-color" content="#fb923c">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<!-- Mobile Optimization --> <!-- Mobile Optimization -->
@ -18,12 +18,12 @@
<meta name="apple-mobile-web-app-title" content="SPJ Angkut"> <meta name="apple-mobile-web-app-title" content="SPJ Angkut">
<!-- iOS Icons --> <!-- iOS Icons -->
<link rel="apple-touch-icon" href="~/icons/icon-152x152.png"> <link rel="apple-touch-icon" href="@Url.Content("~/driver/images/pwa_192.png")">
<link rel="apple-touch-icon" sizes="180x180" href="~/icons/icon-180x180.png"> <link rel="apple-touch-icon" sizes="180x180" href="@Url.Content("~/driver/images/pwa_192.png")">
<!-- Windows Tiles --> <!-- Windows Tiles -->
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png"> <meta name="msapplication-TileImage" content="@Url.Content("~/driver/images/pwa_192.png")">
<meta name="msapplication-TileColor" content="#f97316"> <meta name="msapplication-TileColor" content="#fb923c">
<link rel="manifest" href="@Url.Content("~/driver/manifest.json")" /> <link rel="manifest" href="@Url.Content("~/driver/manifest.json")" />
@* <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> *@ @* <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> *@
@ -40,15 +40,9 @@
@RenderBody() @RenderBody()
<script src="@Url.Content("~/driver/lib/jquery/dist/jquery.min.js")"></script> @await Html.PartialAsync("~/Views/Admin/Transport/SpjDriverUpst/Shared/Components/_PWAinstall.cshtml")
<script src="@Url.Content("~/driver/lib/bootstrap/dist/js/bootstrap.bundle.min.js")"></script> @await Html.PartialAsync("~/Views/Admin/Transport/SpjDriverUpst/Shared/Partials/_Scripts.cshtml")
<script src="@Url.Content("~/driver/js/site.js")">" asp-append-version="true"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
lucide.createIcons();
});
</script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
<dynamic-section name="scripts" /> <dynamic-section name="scripts" />
</body> </body>

View File

@ -77,11 +77,14 @@
--color-slate-50: oklch(98.4% 0.003 247.858); --color-slate-50: oklch(98.4% 0.003 247.858);
--color-slate-100: oklch(96.8% 0.007 247.896); --color-slate-100: oklch(96.8% 0.007 247.896);
--color-slate-200: oklch(92.9% 0.013 255.508); --color-slate-200: oklch(92.9% 0.013 255.508);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-400: oklch(70.4% 0.04 256.788); --color-slate-400: oklch(70.4% 0.04 256.788);
--color-slate-500: oklch(55.4% 0.046 257.417);
--color-slate-600: oklch(44.6% 0.043 257.281); --color-slate-600: oklch(44.6% 0.043 257.281);
--color-slate-700: oklch(37.2% 0.044 257.287); --color-slate-700: oklch(37.2% 0.044 257.287);
--color-slate-800: oklch(27.9% 0.041 260.031); --color-slate-800: oklch(27.9% 0.041 260.031);
--color-slate-900: oklch(20.8% 0.042 265.755); --color-slate-900: oklch(20.8% 0.042 265.755);
--color-slate-950: oklch(12.9% 0.042 264.695);
--color-gray-50: oklch(98.5% 0.002 247.839); --color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-100: oklch(96.7% 0.003 264.542); --color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-200: oklch(92.8% 0.006 264.531);
@ -97,6 +100,7 @@
--spacing: 0.25rem; --spacing: 0.25rem;
--container-xs: 20rem; --container-xs: 20rem;
--container-sm: 24rem; --container-sm: 24rem;
--container-md: 28rem;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75); --text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem; --text-sm: 0.875rem;
@ -109,6 +113,8 @@
--text-xl--line-height: calc(1.75 / 1.25); --text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem; --text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
@ -127,6 +133,7 @@
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
--radius-2xl: 1rem; --radius-2xl: 1rem;
--radius-3xl: 1.5rem; --radius-3xl: 1.5rem;
--radius-4xl: 2rem;
--drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15); --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
--ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1);
@ -324,6 +331,9 @@
.inset-0 { .inset-0 {
inset: calc(var(--spacing) * 0); inset: calc(var(--spacing) * 0);
} }
.inset-x-4 {
inset-inline: calc(var(--spacing) * 4);
}
.start-0 { .start-0 {
inset-inline-start: calc(var(--spacing) * 0); inset-inline-start: calc(var(--spacing) * 0);
} }
@ -369,9 +379,6 @@
.top-6 { .top-6 {
top: calc(var(--spacing) * 6); top: calc(var(--spacing) * 6);
} }
.top-9 {
top: calc(var(--spacing) * 9);
}
.top-12 { .top-12 {
top: calc(var(--spacing) * 12); top: calc(var(--spacing) * 12);
} }
@ -390,6 +397,9 @@
.-right-1 { .-right-1 {
right: calc(var(--spacing) * -1); right: calc(var(--spacing) * -1);
} }
.-right-6 {
right: calc(var(--spacing) * -6);
}
.right-0 { .right-0 {
right: calc(var(--spacing) * 0); right: calc(var(--spacing) * 0);
} }
@ -402,29 +412,32 @@
.right-4 { .right-4 {
right: calc(var(--spacing) * 4); right: calc(var(--spacing) * 4);
} }
.right-5 {
right: calc(var(--spacing) * 5);
}
.right-6 { .right-6 {
right: calc(var(--spacing) * 6); right: calc(var(--spacing) * 6);
} }
.right-8 { .right-8 {
right: calc(var(--spacing) * 8); right: calc(var(--spacing) * 8);
} }
.right-9 {
right: calc(var(--spacing) * 9);
}
.right-16 { .right-16 {
right: calc(var(--spacing) * 16); right: calc(var(--spacing) * 16);
} }
.right-full { .right-full {
right: 100%; right: 100%;
} }
.-bottom-0 {
bottom: calc(var(--spacing) * -0);
}
.-bottom-0\.5 { .-bottom-0\.5 {
bottom: calc(var(--spacing) * -0.5); bottom: calc(var(--spacing) * -0.5);
} }
.-bottom-1 { .-bottom-1 {
bottom: calc(var(--spacing) * -1); bottom: calc(var(--spacing) * -1);
} }
.-bottom-4 { .-bottom-6 {
bottom: calc(var(--spacing) * -4); bottom: calc(var(--spacing) * -6);
} }
.bottom-0 { .bottom-0 {
bottom: calc(var(--spacing) * 0); bottom: calc(var(--spacing) * 0);
@ -435,24 +448,30 @@
.bottom-2 { .bottom-2 {
bottom: calc(var(--spacing) * 2); bottom: calc(var(--spacing) * 2);
} }
.bottom-5 {
bottom: calc(var(--spacing) * 5);
}
.bottom-8 { .bottom-8 {
bottom: calc(var(--spacing) * 8); bottom: calc(var(--spacing) * 8);
} }
.bottom-16 { .bottom-16 {
bottom: calc(var(--spacing) * 16); bottom: calc(var(--spacing) * 16);
} }
.bottom-28 {
bottom: calc(var(--spacing) * 28);
}
.bottom-50 { .bottom-50 {
bottom: calc(var(--spacing) * 50); bottom: calc(var(--spacing) * 50);
} }
.bottom-100 { .bottom-100 {
bottom: calc(var(--spacing) * 100); bottom: calc(var(--spacing) * 100);
} }
.-left-4 {
left: calc(var(--spacing) * -4);
}
.left-0 { .left-0 {
left: calc(var(--spacing) * 0); left: calc(var(--spacing) * 0);
} }
.left-1 {
left: calc(var(--spacing) * 1);
}
.left-1\/2 { .left-1\/2 {
left: calc(1/2 * 100%); left: calc(1/2 * 100%);
} }
@ -501,6 +520,12 @@
.z-99 { .z-99 {
z-index: 99; z-index: 99;
} }
.z-120 {
z-index: 120;
}
.z-130 {
z-index: 130;
}
.z-\[100\] { .z-\[100\] {
z-index: 100; z-index: 100;
} }
@ -762,6 +787,12 @@
.-mr-16 { .-mr-16 {
margin-right: calc(var(--spacing) * -16); margin-right: calc(var(--spacing) * -16);
} }
.mr-1 {
margin-right: calc(var(--spacing) * 1);
}
.mr-1\.5 {
margin-right: calc(var(--spacing) * 1.5);
}
.mr-2 { .mr-2 {
margin-right: calc(var(--spacing) * 2); margin-right: calc(var(--spacing) * 2);
} }
@ -798,11 +829,11 @@
.ml-auto { .ml-auto {
margin-left: auto; margin-left: auto;
} }
.line-clamp-3 { .line-clamp-2 {
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 3; -webkit-line-clamp: 2;
} }
.\!hidden { .\!hidden {
display: none !important; display: none !important;
@ -849,6 +880,9 @@
.aspect-square { .aspect-square {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
} }
.h-0 {
height: calc(var(--spacing) * 0);
}
.h-0\.5 { .h-0\.5 {
height: calc(var(--spacing) * 0.5); height: calc(var(--spacing) * 0.5);
} }
@ -876,6 +910,9 @@
.h-8 { .h-8 {
height: calc(var(--spacing) * 8); height: calc(var(--spacing) * 8);
} }
.h-9 {
height: calc(var(--spacing) * 9);
}
.h-10 { .h-10 {
height: calc(var(--spacing) * 10); height: calc(var(--spacing) * 10);
} }
@ -930,14 +967,11 @@
.h-100 { .h-100 {
height: calc(var(--spacing) * 100); height: calc(var(--spacing) * 100);
} }
.h-\[236px\] {
height: 236px;
}
.h-\[250px\] { .h-\[250px\] {
height: 250px; height: 250px;
} }
.h-\[300px\] { .h-\[340px\] {
height: 300px; height: 340px;
} }
.h-auto { .h-auto {
height: auto; height: auto;
@ -1020,6 +1054,9 @@
.w-36 { .w-36 {
width: calc(var(--spacing) * 36); width: calc(var(--spacing) * 36);
} }
.w-40 {
width: calc(var(--spacing) * 40);
}
.w-48 { .w-48 {
width: calc(var(--spacing) * 48); width: calc(var(--spacing) * 48);
} }
@ -1050,6 +1087,12 @@
.w-max { .w-max {
width: max-content; width: max-content;
} }
.max-w-full {
max-width: 100%;
}
.max-w-md {
max-width: var(--container-md);
}
.max-w-sm { .max-w-sm {
max-width: var(--container-sm); max-width: var(--container-sm);
} }
@ -1059,6 +1102,12 @@
.min-w-0 { .min-w-0 {
min-width: calc(var(--spacing) * 0); min-width: calc(var(--spacing) * 0);
} }
.min-w-\[150px\] {
min-width: 150px;
}
.min-w-max {
min-width: max-content;
}
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
@ -1095,6 +1144,10 @@
.border-collapse { .border-collapse {
border-collapse: collapse; border-collapse: collapse;
} }
.-translate-x-1 {
--tw-translate-x: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-x-1\/2 { .-translate-x-1\/2 {
--tw-translate-x: calc(calc(1/2 * 100%) * -1); --tw-translate-x: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@ -1107,6 +1160,10 @@
--tw-translate-x: calc(var(--spacing) * 16); --tw-translate-x: calc(var(--spacing) * 16);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
} }
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 { .-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1); --tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@ -1209,6 +1266,9 @@
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
.justify-end {
justify-content: flex-end;
}
.gap-0 { .gap-0 {
gap: calc(var(--spacing) * 0); gap: calc(var(--spacing) * 0);
} }
@ -1227,6 +1287,16 @@
.gap-5 { .gap-5 {
gap: calc(var(--spacing) * 5); gap: calc(var(--spacing) * 5);
} }
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-0 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-0\.5 { .space-y-0\.5 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
@ -1276,6 +1346,13 @@
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-8 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-x-2 { .space-x-2 {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-space-x-reverse: 0; --tw-space-x-reverse: 0;
@ -1353,8 +1430,11 @@
.rounded-3xl { .rounded-3xl {
border-radius: var(--radius-3xl); border-radius: var(--radius-3xl);
} }
.rounded-\[24px\] { .rounded-4xl {
border-radius: 24px; border-radius: var(--radius-4xl);
}
.rounded-\[28px\] {
border-radius: 28px;
} }
.rounded-\[32px\] { .rounded-\[32px\] {
border-radius: 32px; border-radius: 32px;
@ -1503,6 +1583,9 @@
.border-gray-400 { .border-gray-400 {
border-color: var(--color-gray-400); border-color: var(--color-gray-400);
} }
.border-gray-500 {
border-color: var(--color-gray-500);
}
.border-green-100 { .border-green-100 {
border-color: var(--color-green-100); border-color: var(--color-green-100);
} }
@ -1515,15 +1598,12 @@
.border-green-400 { .border-green-400 {
border-color: var(--color-green-400); border-color: var(--color-green-400);
} }
.border-green-600\/20 {
border-color: color-mix(in srgb, oklch(62.7% 0.194 149.214) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-green-600) 20%, transparent);
}
}
.border-lime-400 { .border-lime-400 {
border-color: var(--color-lime-400); border-color: var(--color-lime-400);
} }
.border-orange-100 {
border-color: var(--color-orange-100);
}
.border-orange-200 { .border-orange-200 {
border-color: var(--color-orange-200); border-color: var(--color-orange-200);
} }
@ -1560,6 +1640,12 @@
.border-white { .border-white {
border-color: var(--color-white); border-color: var(--color-white);
} }
.border-white\/5 {
border-color: color-mix(in srgb, #fff 5%, transparent);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-white) 5%, transparent);
}
}
.border-white\/10 { .border-white\/10 {
border-color: color-mix(in srgb, #fff 10%, transparent); border-color: color-mix(in srgb, #fff 10%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -1578,12 +1664,6 @@
border-color: color-mix(in oklab, var(--color-white) 30%, transparent); border-color: color-mix(in oklab, var(--color-white) 30%, transparent);
} }
} }
.border-white\/70 {
border-color: color-mix(in srgb, #fff 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-white) 70%, transparent);
}
}
.border-yellow-100 { .border-yellow-100 {
border-color: var(--color-yellow-100); border-color: var(--color-yellow-100);
} }
@ -1608,6 +1688,12 @@
.bg-black { .bg-black {
background-color: var(--color-black); background-color: var(--color-black);
} }
.bg-black\/10 {
background-color: color-mix(in srgb, #000 10%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-black) 10%, transparent);
}
}
.bg-black\/20 { .bg-black\/20 {
background-color: color-mix(in srgb, #000 20%, transparent); background-color: color-mix(in srgb, #000 20%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -1644,6 +1730,12 @@
.bg-blue-500 { .bg-blue-500 {
background-color: var(--color-blue-500); background-color: var(--color-blue-500);
} }
.bg-blue-600 {
background-color: var(--color-blue-600);
}
.bg-cyan-400 {
background-color: var(--color-cyan-400);
}
.bg-cyan-400\/10 { .bg-cyan-400\/10 {
background-color: color-mix(in srgb, oklch(78.9% 0.154 211.53) 10%, transparent); background-color: color-mix(in srgb, oklch(78.9% 0.154 211.53) 10%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -1707,6 +1799,9 @@
.bg-indigo-300 { .bg-indigo-300 {
background-color: var(--color-indigo-300); background-color: var(--color-indigo-300);
} }
.bg-lime-500 {
background-color: var(--color-lime-500);
}
.bg-lime-500\/15 { .bg-lime-500\/15 {
background-color: color-mix(in srgb, oklch(76.8% 0.233 130.85) 15%, transparent); background-color: color-mix(in srgb, oklch(76.8% 0.233 130.85) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -1728,6 +1823,9 @@
.bg-orange-500 { .bg-orange-500 {
background-color: var(--color-orange-500); background-color: var(--color-orange-500);
} }
.bg-orange-700 {
background-color: var(--color-orange-700);
}
.bg-orange-700\/30 { .bg-orange-700\/30 {
background-color: color-mix(in srgb, oklch(55.3% 0.195 38.402) 30%, transparent); background-color: color-mix(in srgb, oklch(55.3% 0.195 38.402) 30%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -1752,12 +1850,33 @@
.bg-red-500 { .bg-red-500 {
background-color: var(--color-red-500); background-color: var(--color-red-500);
} }
.bg-slate-50 {
background-color: var(--color-slate-50);
}
.bg-slate-100 { .bg-slate-100 {
background-color: var(--color-slate-100); background-color: var(--color-slate-100);
} }
.bg-slate-200 {
background-color: var(--color-slate-200);
}
.bg-slate-300 {
background-color: var(--color-slate-300);
}
.bg-slate-400 { .bg-slate-400 {
background-color: var(--color-slate-400); background-color: var(--color-slate-400);
} }
.bg-slate-900 {
background-color: var(--color-slate-900);
}
.bg-slate-950 {
background-color: var(--color-slate-950);
}
.bg-slate-950\/60 {
background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 60%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-slate-950) 60%, transparent);
}
}
.bg-transparent { .bg-transparent {
background-color: transparent; background-color: transparent;
} }
@ -1776,6 +1895,12 @@
background-color: color-mix(in oklab, var(--color-white) 10%, transparent); background-color: color-mix(in oklab, var(--color-white) 10%, transparent);
} }
} }
.bg-white\/15 {
background-color: color-mix(in srgb, #fff 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-white) 15%, transparent);
}
}
.bg-white\/20 { .bg-white\/20 {
background-color: color-mix(in srgb, #fff 20%, transparent); background-color: color-mix(in srgb, #fff 20%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -1788,12 +1913,6 @@
background-color: color-mix(in oklab, var(--color-white) 70%, transparent); background-color: color-mix(in oklab, var(--color-white) 70%, transparent);
} }
} }
.bg-white\/95 {
background-color: color-mix(in srgb, #fff 95%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-white) 95%, transparent);
}
}
.bg-yellow-50 { .bg-yellow-50 {
background-color: var(--color-yellow-50); background-color: var(--color-yellow-50);
} }
@ -2067,6 +2186,9 @@
.p-1 { .p-1 {
padding: calc(var(--spacing) * 1); padding: calc(var(--spacing) * 1);
} }
.p-1\.5 {
padding: calc(var(--spacing) * 1.5);
}
.p-2 { .p-2 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 2);
} }
@ -2118,6 +2240,9 @@
.py-1 { .py-1 {
padding-block: calc(var(--spacing) * 1); padding-block: calc(var(--spacing) * 1);
} }
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@ -2205,6 +2330,12 @@
.pt-10 { .pt-10 {
padding-top: calc(var(--spacing) * 10); padding-top: calc(var(--spacing) * 10);
} }
.pt-12 {
padding-top: calc(var(--spacing) * 12);
}
.pr-4 {
padding-right: calc(var(--spacing) * 4);
}
.pb-0 { .pb-0 {
padding-bottom: calc(var(--spacing) * 0); padding-bottom: calc(var(--spacing) * 0);
} }
@ -2290,6 +2421,10 @@
font-size: var(--text-2xl); font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height)); line-height: var(--tw-leading, var(--text-2xl--line-height));
} }
.text-3xl {
font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height));
}
.text-base { .text-base {
font-size: var(--text-base); font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height)); line-height: var(--tw-leading, var(--text-base--line-height));
@ -2363,6 +2498,14 @@
--tw-tracking: 0.3em; --tw-tracking: 0.3em;
letter-spacing: 0.3em; letter-spacing: 0.3em;
} }
.tracking-\[0\.22em\] {
--tw-tracking: 0.22em;
letter-spacing: 0.22em;
}
.tracking-\[0\.24em\] {
--tw-tracking: 0.24em;
letter-spacing: 0.24em;
}
.tracking-tight { .tracking-tight {
--tw-tracking: var(--tracking-tight); --tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
@ -2395,6 +2538,15 @@
.break-all { .break-all {
word-break: break-all; word-break: break-all;
} }
.whitespace-nowrap {
white-space: nowrap;
}
.\!text-white {
color: var(--color-white) !important;
}
.text-amber-500 {
color: var(--color-amber-500);
}
.text-amber-600 { .text-amber-600 {
color: var(--color-amber-600); color: var(--color-amber-600);
} }
@ -2425,6 +2577,12 @@
.text-gray-500 { .text-gray-500 {
color: var(--color-gray-500); color: var(--color-gray-500);
} }
.text-gray-500\/80 {
color: color-mix(in srgb, oklch(55.1% 0.027 264.364) 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-gray-500) 80%, transparent);
}
}
.text-gray-600 { .text-gray-600 {
color: var(--color-gray-600); color: var(--color-gray-600);
} }
@ -2437,15 +2595,12 @@
.text-gray-900 { .text-gray-900 {
color: var(--color-gray-900); color: var(--color-gray-900);
} }
.text-green-50 {
color: var(--color-green-50);
}
.text-green-100 { .text-green-100 {
color: var(--color-green-100); color: var(--color-green-100);
} }
.text-green-100\/70 {
color: color-mix(in srgb, oklch(96.2% 0.044 156.743) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-green-100) 70%, transparent);
}
}
.text-green-400 { .text-green-400 {
color: var(--color-green-400); color: var(--color-green-400);
} }
@ -2488,6 +2643,9 @@
.text-orange-700 { .text-orange-700 {
color: var(--color-orange-700); color: var(--color-orange-700);
} }
.text-red-400 {
color: var(--color-red-400);
}
.text-red-500 { .text-red-500 {
color: var(--color-red-500); color: var(--color-red-500);
} }
@ -2506,9 +2664,15 @@
.text-red-800 { .text-red-800 {
color: var(--color-red-800); color: var(--color-red-800);
} }
.text-slate-50 {
color: var(--color-slate-50);
}
.text-slate-400 { .text-slate-400 {
color: var(--color-slate-400); color: var(--color-slate-400);
} }
.text-slate-500 {
color: var(--color-slate-500);
}
.text-slate-600 { .text-slate-600 {
color: var(--color-slate-600); color: var(--color-slate-600);
} }
@ -2518,6 +2682,9 @@
.text-slate-800 { .text-slate-800 {
color: var(--color-slate-800); color: var(--color-slate-800);
} }
.text-slate-900 {
color: var(--color-slate-900);
}
.text-transparent { .text-transparent {
color: transparent; color: transparent;
} }
@ -2530,6 +2697,12 @@
color: color-mix(in oklab, var(--color-white) 70%, transparent); color: color-mix(in oklab, var(--color-white) 70%, transparent);
} }
} }
.text-white\/80 {
color: color-mix(in srgb, #fff 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-white) 80%, transparent);
}
}
.text-white\/90 { .text-white\/90 {
color: color-mix(in srgb, #fff 90%, transparent); color: color-mix(in srgb, #fff 90%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@ -2638,6 +2811,10 @@
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.ring-1 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-2 { .ring-2 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@ -2652,6 +2829,15 @@
--tw-shadow-color: color-mix(in oklab, var(--color-gray-200) var(--tw-shadow-alpha), transparent); --tw-shadow-color: color-mix(in oklab, var(--color-gray-200) var(--tw-shadow-alpha), transparent);
} }
} }
.ring-black {
--tw-ring-color: var(--color-black);
}
.ring-black\/5 {
--tw-ring-color: color-mix(in srgb, #000 5%, transparent);
@supports (color: color-mix(in lab, red, red)) {
--tw-ring-color: color-mix(in oklab, var(--color-black) 5%, transparent);
}
}
.ring-gray-200 { .ring-gray-200 {
--tw-ring-color: var(--color-gray-200); --tw-ring-color: var(--color-gray-200);
} }
@ -2704,11 +2890,6 @@
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
.backdrop-blur {
--tw-backdrop-blur: blur(8px);
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.backdrop-blur-lg { .backdrop-blur-lg {
--tw-backdrop-blur: blur(var(--blur-lg)); --tw-backdrop-blur: blur(var(--blur-lg));
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
@ -2775,6 +2956,10 @@
--tw-duration: 300ms; --tw-duration: 300ms;
transition-duration: 300ms; transition-duration: 300ms;
} }
.duration-500 {
--tw-duration: 500ms;
transition-duration: 500ms;
}
.ease-in { .ease-in {
--tw-ease: var(--ease-in); --tw-ease: var(--ease-in);
transition-timing-function: var(--ease-in); transition-timing-function: var(--ease-in);
@ -2864,6 +3049,11 @@
scale: var(--tw-scale-x) var(--tw-scale-y); scale: var(--tw-scale-x) var(--tw-scale-y);
} }
} }
.group-active\:rotate-180 {
&:is(:where(.group):active *) {
rotate: 180deg;
}
}
.group-active\:bg-gray-50 { .group-active\:bg-gray-50 {
&:is(:where(.group):active *) { &:is(:where(.group):active *) {
background-color: var(--color-gray-50); background-color: var(--color-gray-50);
@ -2974,6 +3164,13 @@
} }
} }
} }
.hover\:border-green-400 {
&:hover {
@media (hover: hover) {
border-color: var(--color-green-400);
}
}
}
.hover\:border-orange-200 { .hover\:border-orange-200 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -2995,6 +3192,13 @@
} }
} }
} }
.hover\:bg-blue-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-700);
}
}
}
.hover\:bg-gray-50 { .hover\:bg-gray-50 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -3065,6 +3269,13 @@
} }
} }
} }
.hover\:bg-slate-800 {
&:hover {
@media (hover: hover) {
background-color: var(--color-slate-800);
}
}
}
.hover\:bg-white\/10 { .hover\:bg-white\/10 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -3276,6 +3487,23 @@
outline-style: none; outline-style: none;
} }
} }
.focus-visible\:ring-2 {
&:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus-visible\:ring-slate-400 {
&:focus-visible {
--tw-ring-color: var(--color-slate-400);
}
}
.focus-visible\:outline-none {
&:focus-visible {
--tw-outline-style: none;
outline-style: none;
}
}
.active\:scale-90 { .active\:scale-90 {
&:active { &:active {
--tw-scale-x: 90%; --tw-scale-x: 90%;
@ -3292,6 +3520,36 @@
scale: var(--tw-scale-x) var(--tw-scale-y); scale: var(--tw-scale-x) var(--tw-scale-y);
} }
} }
.disabled\:cursor-not-allowed {
&:disabled {
cursor: not-allowed;
}
}
.disabled\:opacity-50 {
&:disabled {
opacity: 50%;
}
}
.sm\:flex-1 {
@media (width >= 40rem) {
flex: 1;
}
}
.sm\:grid-cols-2 {
@media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:flex-row {
@media (width >= 40rem) {
flex-direction: row;
}
}
.sm\:items-center {
@media (width >= 40rem) {
align-items: center;
}
}
.lg\:max-w-sm { .lg\:max-w-sm {
@media (width >= 64rem) { @media (width >= 64rem) {
max-width: var(--container-sm); max-width: var(--container-sm);

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,291 @@
document.addEventListener('DOMContentLoaded', function () {
const pageEl = document.getElementById('history-page');
if (!pageEl) return;
const apiUrl = pageEl.dataset.historyApi;
const detailBase = pageEl.dataset.historyDetailBase || '/upst/history/details/__id__';
const loadingEl = document.getElementById('history-loading');
const emptyEl = document.getElementById('history-empty');
const listEl = document.getElementById('history-list');
const paginationEl = document.getElementById('history-pagination');
const pageInfoEl = document.getElementById('history-page-info');
const totalInfoEl = document.getElementById('history-total-info');
const pageButtonsEl = document.getElementById('history-page-buttons');
const prevBtn = document.getElementById('history-prev-page');
const nextBtn = document.getElementById('history-next-page');
const fromDateInput = document.getElementById('history-from-date-filter');
const toDateInput = document.getElementById('history-to-date-filter');
const applyFilterBtn = document.getElementById('history-apply-filter');
const resetFilterBtn = document.getElementById('history-reset-filter');
const state = {
page: 1,
pageSize: 5,
totalPages: 1,
totalItems: 0,
fromDate: '',
toDate: ''
};
function formatTanggal(dateString) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('id-ID', {
day: '2-digit',
month: 'short',
year: 'numeric'
}).format(date);
}
function formatWaktu(dateString) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('id-ID', {
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
function getStatusMarkup(status) {
if ((status || '').toLowerCase() === 'completed') {
return '<span class="bg-green-50 text-green-600 px-3 py-1 rounded-lg text-[10px] font-black uppercase border border-green-100">Selesai</span>';
}
return '<span class="bg-blue-50 text-blue-600 px-3 py-1 rounded-lg text-[10px] font-black uppercase border border-blue-100 animate-pulse">Proses</span>';
}
function buildDetailUrl(id) {
return detailBase.replace('__id__', String(id));
}
function renderItems(items) {
listEl.innerHTML = '';
items.forEach(function (item) {
const card = document.createElement('a');
card.href = buildDetailUrl(item.id);
card.className = 'block group';
card.innerHTML = `
<div class="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 group-hover:shadow-md group-hover:-translate-y-1 transition-all duration-300">
<div class="flex justify-between items-start mb-4">
<div class="space-y-0.5">
<p class="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Nomor Dokumen</p>
<p class="text-sm font-black text-gray-800">${item.noSpj}</p>
</div>
${getStatusMarkup(item.status)}
</div>
<div class="flex items-center gap-4 bg-gray-50/80 p-3 rounded-2xl border border-dashed border-gray-200">
<div class="w-12 h-12 bg-upst rounded-xl flex items-center justify-center shadow-inner">
<i class="w-6 h-6 text-white" data-lucide="truck"></i>
</div>
<div class="flex-1">
<div class="flex justify-between items-baseline gap-2">
<h2 class="font-black text-gray-900 leading-tight">${item.plat}</h2>
<span class="text-[11px] font-bold text-upst whitespace-nowrap">${formatWaktu(item.tanggalWaktu)}</span>
</div>
<div class="flex justify-between items-center gap-2">
<span class="text-xs text-gray-500 font-medium">${item.kode}</span>
<span class="text-[10px] text-gray-400 font-semibold whitespace-nowrap">${formatTanggal(item.tanggalWaktu)}</span>
</div>
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<div class="w-2 h-2 rounded-full bg-upst/40"></div>
<div class="flex flex-col min-w-0">
<span class="text-[10px] text-gray-400 font-bold uppercase">Tujuan Akhir</span>
<span class="text-sm font-bold text-gray-700 truncate">${item.tujuan}</span>
</div>
</div>
<div class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-upst transition-colors shrink-0">
<i class="w-4 h-4" data-lucide="chevron-right"></i>
</div>
</div>
</div>
`;
listEl.appendChild(card);
});
if (window.lucide) {
window.lucide.createIcons();
}
}
function renderPagination() {
const hasItems = state.totalItems > 0;
paginationEl.classList.toggle('hidden', !hasItems);
if (!hasItems) return;
pageInfoEl.textContent = `Halaman ${state.page} dari ${state.totalPages}`;
totalInfoEl.textContent = `${state.totalItems} data`;
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= state.totalPages;
pageButtonsEl.innerHTML = '';
const pages = [];
const startPage = Math.max(1, state.page - 2);
const endPage = Math.min(state.totalPages, state.page + 2);
if (startPage > 1) {
pages.push(1);
if (startPage > 2) pages.push('ellipsis-left');
}
for (let page = startPage; page <= endPage; page++) {
pages.push(page);
}
if (endPage < state.totalPages) {
if (endPage < state.totalPages - 1) pages.push('ellipsis-right');
pages.push(state.totalPages);
}
pages.forEach(function (page) {
if (typeof page !== 'number') {
const ellipsis = document.createElement('span');
ellipsis.className = 'w-10 h-10 flex items-center justify-center text-xs font-black text-gray-400';
ellipsis.textContent = '...';
pageButtonsEl.appendChild(ellipsis);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = String(page);
btn.className = page === state.page
? 'w-10 h-10 rounded-2xl bg-upst text-white text-xs font-black'
: 'w-10 h-10 rounded-2xl border border-gray-200 text-gray-700 text-xs font-black bg-white';
btn.addEventListener('click', function () {
if (page === state.page) return;
loadHistory(page);
});
pageButtonsEl.appendChild(btn);
});
}
function setLoading(isLoading) {
loadingEl.classList.toggle('hidden', !isLoading);
if (isLoading) {
emptyEl.classList.add('hidden');
listEl.innerHTML = '';
paginationEl.classList.add('hidden');
}
}
function showEmpty() {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
paginationEl.classList.add('hidden');
if (window.lucide) {
window.lucide.createIcons();
}
}
async function loadHistory(page) {
state.page = page || 1;
setLoading(true);
try {
const params = new URLSearchParams({
page: String(state.page),
pageSize: String(state.pageSize)
});
if (state.fromDate) {
params.set('fromDate', state.fromDate);
}
if (state.toDate) {
params.set('toDate', state.toDate);
}
const response = await fetch(`${apiUrl}?${params.toString()}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error('Gagal memuat history');
}
const data = await response.json();
state.page = data.page || 1;
state.totalPages = data.totalPages || 1;
state.totalItems = data.totalItems || 0;
loadingEl.classList.add('hidden');
if (!data.items || data.items.length === 0) {
showEmpty();
return;
}
emptyEl.classList.add('hidden');
renderItems(data.items);
renderPagination();
} catch (error) {
console.error(error);
loadingEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
emptyEl.innerHTML = `
<div class="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="w-8 h-8 text-red-400" data-lucide="triangle-alert"></i>
</div>
<h3 class="text-base font-black text-gray-900 mb-1">Gagal Memuat Riwayat</h3>
<p class="text-sm text-gray-500">Silakan coba lagi beberapa saat.</p>
`;
if (window.lucide) {
window.lucide.createIcons();
}
}
}
function applyDateRangeFilter() {
const nextFromDate = fromDateInput?.value || '';
const nextToDate = toDateInput?.value || '';
if (nextFromDate && nextToDate && nextFromDate > nextToDate) {
const temp = nextFromDate;
state.fromDate = nextToDate;
state.toDate = temp;
if (fromDateInput) fromDateInput.value = state.fromDate;
if (toDateInput) toDateInput.value = state.toDate;
} else {
state.fromDate = nextFromDate;
state.toDate = nextToDate;
}
loadHistory(1);
}
applyFilterBtn?.addEventListener('click', function () {
applyDateRangeFilter();
});
resetFilterBtn?.addEventListener('click', function () {
state.fromDate = '';
state.toDate = '';
if (fromDateInput) fromDateInput.value = '';
if (toDateInput) toDateInput.value = '';
loadHistory(1);
});
[fromDateInput, toDateInput].forEach(function (input) {
input?.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
applyDateRangeFilter();
}
});
});
prevBtn?.addEventListener('click', function () {
if (state.page > 1) {
loadHistory(state.page - 1);
}
});
nextBtn?.addEventListener('click', function () {
if (state.page < state.totalPages) {
loadHistory(state.page + 1);
}
});
loadHistory(1);
});

View File

@ -3,20 +3,20 @@
{ {
"name": "CV Tri Mitra Utama - Shell Radio Dalam", "name": "CV Tri Mitra Utama - Shell Radio Dalam",
"alamat": "Kp. Pertanian II Rt.004 Rw.001 Kel. Klender Kec, Duren Sawit, Kota Adm. Jakarta Timur 13470", "alamat": "Kp. Pertanian II Rt.004 Rw.001 Kel. Klender Kec, Duren Sawit, Kota Adm. Jakarta Timur 13470",
"longitude": -6.260066361357777, "longitude": -6.26012668512782,
"latitude": 106.78918653869111 "latitude": 106.8712511969409
}, },
{ {
"name": "Jakarta Islamic Hospital", "name": "Jakarta Islamic Hospital",
"alamat": "Gang Masjid Baitusalam, Jl. E No.19, RT.10/RW.11, Cipinang, Kec. Pulo Gadung, Kota Jakarta Timur, Daerah Khusus Ibukota Jakarta 13240", "alamat": "Gang Masjid Baitusalam, Jl. E No.19, RT.10/RW.11, Cipinang, Kec. Pulo Gadung, Kota Jakarta Timur, Daerah Khusus Ibukota Jakarta 13240",
"longitude": -6.168850536704003, "longitude": -6.2550852145095694,
"latitude": 106.87091508600079 "latitude": 106.86310593093889
}, },
{ {
"name": "Puskesmas Kelurahan Klender 1", "name": "Puskesmas Kelurahan Klender 1",
"alamat": "Jl. Pertanian Tengah No.7, RT.4/RW.2, Klender, Kec. Duren Sawit, Kota Jakarta Timur, Daerah Khusus Ibukota Jakarta 13430", "alamat": "Jl. Pertanian Tengah No.7, RT.4/RW.2, Klender, Kec. Duren Sawit, Kota Jakarta Timur, Daerah Khusus Ibukota Jakarta 13430",
"longitude": -6.2146980384303845, "longitude": -6.268768227222534,
"latitude": 106.89777460376108 "latitude": 106.87026643078956
} }
] ]
} }

View File

@ -1,93 +1,44 @@
{ {
"id": "/upst",
"name": "eSPJ - Surat Perjalanan Dinas", "name": "eSPJ - Surat Perjalanan Dinas",
"short_name": "eSPJ", "short_name": "eSPJ",
"description": "Aplikasi pengelolaan Surat Perjalanan Dinas yang modern dan efisien", "description": "Aplikasi pengelolaan Surat Perjalanan Dinas yang modern dan efisien",
"start_url": "/", "start_url": "/upst",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#fb923c", "theme_color": "#fb923c",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"scope": "/", "scope": "/upst",
"lang": "id", "lang": "id",
"dir": "ltr", "dir": "ltr",
"icons": [ "icons": [
{ {
"src": "icon-72.png", "src": "/driver/images/pwa_192.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icon-384.png", "src": "/driver/images/pwa_512.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
} }
], ],
"screenshots": [
{
"src": "screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "screenshot-narrow.png",
"sizes": "360x640",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [ "shortcuts": [
{ {
"name": "Login", "name": "Home UPST",
"short_name": "Login", "short_name": "Home",
"description": "Masuk ke aplikasi eSPJ", "description": "Buka dashboard utama UPST",
"url": "/login", "url": "/upst",
"icons": [{ "src": "icon-96.png", "sizes": "96x96" }] "icons": [{ "src": "/driver/images/pwa_192.png", "sizes": "192x192", "type": "image/png" }]
}, },
{ {
"name": "Dashboard", "name": "Halaman Kosong",
"short_name": "Dashboard", "short_name": "Kosong",
"description": "Buka dashboard utama", "description": "Buka halaman alternatif UPST",
"url": "/dashboard", "url": "/upst/kosong",
"icons": [{ "src": "icon-96.png", "sizes": "96x96" }] "icons": [{ "src": "/driver/images/pwa_192.png", "sizes": "192x192", "type": "image/png" }]
} }
] ]
} }

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline - DLH DKI Jakarta</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
</head>
<body class="bg-background text-foreground flex min-h-screen flex-col items-center justify-center p-6 bg-slate-50">
<div class="max-w-md w-full space-y-8 text-center animate-in">
<div class="relative flex justify-center">
<div class="absolute inset-0 m-auto h-24 w-24 animate-ping rounded-full bg-slate-200 opacity-20"></div>
<div class="relative rounded-full bg-white p-6 shadow-sm border border-slate-200">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="text-slate-400">
<path
d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0 1 19 12.55M5 12.55a10.94 10.94 0 0 1 5.17-2.39M10.71 5.05A16 16 0 0 1 22.58 9M1.42 9a15.91 15.91 0 0 1 4.7-2.88M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
</svg>
</div>
</div>
<div class="space-y-2">
<h1 class="text-3xl font-bold tracking-tight text-slate-900">Koneksi Terputus</h1>
<p class="text-muted-foreground text-slate-500">
Sepertinya perangkatmu sedang tidak terhubung ke internet. Silakan cek koneksi Wi-Fi atau data seluler
kamu.
</p>
</div>
<div class="pt-4">
<button onclick="window.location.reload()"
class="inline-flex h-10 items-center justify-center rounded-md bg-slate-900 px-8 text-sm font-medium text-slate-50 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 transition-colors shadow-lg">
Coba Lagi
</button>
</div>
<div class="pt-12 flex flex-col items-center gap-2 opacity-60">
<div class="h-1 w-12 bg-slate-300 rounded-full"></div>
<p class="text-xs font-semibold tracking-widest uppercase text-slate-400">DLH DKI Jakarta</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,77 @@
const CACHE_NAME = "espj-upst-pwa-v1";
const OFFLINE_URL = "/driver/offline.html";
const PRECACHE_URLS = [
"/upst",
"/upst/kosong",
"/driver/manifest.json",
"/driver/favicon.ico",
"/driver/images/pwa_192.png",
"/driver/images/pwa_512.png",
OFFLINE_URL
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET") {
return;
}
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
return response;
})
.catch(async () => {
const cachedPage = await caches.match(request);
return cachedPage || caches.match(OFFLINE_URL);
})
);
return;
}
if (url.origin !== self.location.origin) {
return;
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
const networkFetch = fetch(request)
.then((response) => {
if (response && response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseClone));
}
return response;
})
.catch(() => cachedResponse);
return cachedResponse || networkFetch;
})
);
});