Merge pull request 'Add backup images for login and background removal' (#73) from UI into Dev
Reviewed-on: #73
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Uoms;
|
using ERPCore.Dtos.Uoms;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
@@ -15,8 +16,9 @@ public sealed class UomsController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||||
=> Ok(await _uoms.ListAsync(query, ct));
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||||
@@ -26,4 +28,14 @@ public sealed class UomsController : ApiControllerBase
|
|||||||
var dto = await _uoms.CreateAsync(request, ct);
|
var dto = await _uoms.CreateAsync(request, ct);
|
||||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{uomId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Domain.Entities;
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -10,4 +12,8 @@ public class Uom
|
|||||||
{
|
{
|
||||||
public int UomId { get; set; }
|
public int UomId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
namespace ERPCore.Dtos.Uoms;
|
namespace ERPCore.Dtos.Uoms;
|
||||||
|
|
||||||
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||||
public sealed record UomDto(int UomId, string Name);
|
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
public sealed class CreateUomRequest
|
public sealed class CreateUomRequest
|
||||||
{
|
{
|
||||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateUomStatusRequest
|
||||||
|
{
|
||||||
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
@@ -13,5 +14,13 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
|
|||||||
|
|
||||||
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
|
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
|
||||||
builder.HasIndex(u => u.Name).IsUnique();
|
builder.HasIndex(u => u.Name).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(u => u.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|
||||||
|
builder.Property(u => u.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
builder.HasIndex(u => u.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUomLifecycleColumnsLocal : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "uoms",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Status",
|
||||||
|
table: "uoms",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Active");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "uoms",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_uoms_Status",
|
||||||
|
table: "uoms",
|
||||||
|
column: "Status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_uoms_Status",
|
||||||
|
table: "uoms");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "uoms");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Status",
|
||||||
|
table: "uoms");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedAt",
|
||||||
|
table: "uoms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6000,16 +6000,31 @@ namespace ERPCore.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.HasKey("UomId");
|
b.HasKey("UomId");
|
||||||
|
|
||||||
b.HasIndex("Name")
|
b.HasIndex("Name")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.ToTable("uoms", (string)null);
|
b.ToTable("uoms", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Uoms;
|
using ERPCore.Dtos.Uoms;
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
|||||||
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||||
public interface IUomService
|
public interface IUomService
|
||||||
{
|
{
|
||||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||||
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
||||||
|
Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Domain.Entities;
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
using ERPCore.Dtos.Common;
|
using ERPCore.Dtos.Common;
|
||||||
using ERPCore.Dtos.Uoms;
|
using ERPCore.Dtos.Uoms;
|
||||||
using ERPCore.Infra.UoW;
|
using ERPCore.Infra.UoW;
|
||||||
@@ -20,7 +21,7 @@ public sealed class UomService : IUomService
|
|||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var q = _uoms.Query().AsNoTracking();
|
var q = _uoms.Query().AsNoTracking();
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -28,11 +29,12 @@ public sealed class UomService : IUomService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
|
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
|
||||||
}
|
}
|
||||||
|
if (status is not null) q = q.Where(u => u.Status == status);
|
||||||
|
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var rows = await q.OrderBy(u => u.Name)
|
var rows = await q.OrderBy(u => u.Name)
|
||||||
.Skip(query.Skip).Take(query.PageSize)
|
.Skip(query.Skip).Take(query.PageSize)
|
||||||
.Select(u => new UomDto(u.UomId, u.Name))
|
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
||||||
@@ -44,10 +46,20 @@ public sealed class UomService : IUomService
|
|||||||
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
|
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
|
||||||
throw new ConflictException($"A UOM named '{name}' already exists.");
|
throw new ConflictException($"A UOM named '{name}' already exists.");
|
||||||
|
|
||||||
var uom = new Uom { Name = name };
|
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||||
await _uoms.AddAsync(uom, ct);
|
await _uoms.AddAsync(uom, ct);
|
||||||
await _uow.SaveChangesAsync(ct);
|
await _uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return new UomDto(uom.UomId, uom.Name);
|
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var uom = await _uoms.GetByIdAsync(uomId, ct)
|
||||||
|
?? throw new NotFoundException($"UOM {uomId} was not found.");
|
||||||
|
|
||||||
|
uom.Status = status;
|
||||||
|
uom.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _uow.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,11 +161,11 @@ export default function BankAccountsPage() {
|
|||||||
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
{gl ? `${gl.accountCode} — ${gl.accountName}` : `#${a.glAccountId}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export default function ChequeBookDetailPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatReportDate(p.issueDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||||
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
{p.amount !== null ? formatAmount(p.amount) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -142,15 +142,15 @@ export default function ChequeBooksPage() {
|
|||||||
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
|
||||||
>
|
>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
{bank ? bank.accountName : `#${b.bankAccountId}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
{b.startChequeNo} – {b.endChequeNo}
|
{b.startChequeNo} – {b.endChequeNo}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatReportDate(b.receivedDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
|
||||||
{b.status}
|
{b.status}
|
||||||
|
|||||||
@@ -146,12 +146,12 @@ export default function ReceivedChequesPage() {
|
|||||||
>
|
>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
|
<TableCell className="px-3 py-3.5">{c.receivedFromType}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
{c.branchId ? (branchesById.get(c.branchId)?.name ?? `#${c.branchId}`) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
|
<TableCell className="px-3 py-3.5">{formatReportDate(c.receivedDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
|
||||||
{c.status}
|
{c.status}
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
const STATUS_STYLE: Record<AttendanceUploadBatch["status"], string> = {
|
||||||
Draft: "bg-muted text-muted-foreground",
|
Draft: "bg-muted text-muted-foreground",
|
||||||
@@ -137,16 +138,7 @@ export default function AttendanceBatchesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>Next</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function GeneralLedgerReportPage() {
|
|||||||
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
|
<TableCell className="px-3 py-2.5">{row.narration ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
|
||||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
Boxes,
|
Boxes,
|
||||||
Clock,
|
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@@ -128,7 +127,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6">
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/dashboard/stock/reorder-alerts">
|
<Link href="/dashboard/stock/reorder-alerts">
|
||||||
@@ -137,9 +136,6 @@ export default function DashboardPage() {
|
|||||||
<Link href="/dashboard/stock/enquiry">
|
<Link href="/dashboard/stock/enquiry">
|
||||||
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/procurement/purchase-orders">
|
|
||||||
<StatCard label="Pending Approval POs" value={stats.pendingApprovalPurchaseOrders} icon={Clock} />
|
|
||||||
</Link>
|
|
||||||
<Link href="/dashboard/receiving/grn">
|
<Link href="/dashboard/receiving/grn">
|
||||||
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -154,10 +150,11 @@ export default function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
!error && Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
|
||||||
@@ -180,7 +177,7 @@ export default function DashboardPage() {
|
|||||||
{
|
{
|
||||||
label: "Stock Value (LKR)",
|
label: "Stock Value (LKR)",
|
||||||
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
data: stats.stockValuationByWarehouse.map((w) => w.total),
|
||||||
backgroundColor: "#6366f1",
|
backgroundColor: "#E11D48",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -215,6 +212,7 @@ export default function DashboardPage() {
|
|||||||
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
|
||||||
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -243,9 +241,9 @@ export default function DashboardPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{movements.map((entry) => (
|
{movements.map((entry) => (
|
||||||
<TableRow key={entry.ledgerId}>
|
<TableRow key={entry.ledgerId}>
|
||||||
<TableCell className="font-medium text-foreground">#{entry.itemId}</TableCell>
|
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell>
|
||||||
{warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
|
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -260,11 +258,11 @@ export default function DashboardPage() {
|
|||||||
{entry.direction}
|
{entry.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
|
<TableCell>{entry.qtyBase}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell>
|
||||||
{entry.sourceDocType} #{entry.sourceDocId}
|
{entry.sourceDocType} #{entry.sourceDocId}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground">
|
<TableCell className="text-right">
|
||||||
{new Date(entry.createdAt).toLocaleDateString()}
|
{new Date(entry.createdAt).toLocaleDateString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<
|
|||||||
{line.grnDocNo}
|
{line.grnDocNo}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ function NewPurchaseInvoiceContent() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
|
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
|
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
|
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
|
||||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
|
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
|
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
|
||||||
|
|
||||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -19,7 +20,7 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type StatusFilter = PurchaseInvoiceStatus | "All"
|
type StatusFilter = PurchaseInvoiceStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
const tabs: { value: StatusFilter; label: string }[] = [
|
const tabs: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "All", label: "All" },
|
{ value: "All", label: "All" },
|
||||||
{ value: "Draft", label: "Draft" },
|
{ value: "Draft", label: "Draft" },
|
||||||
@@ -198,10 +199,10 @@ export default function PurchaseInvoicesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
|
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
|
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">
|
<TableCell className="px-4 py-3.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
@@ -271,22 +272,9 @@ export default function PurchaseInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && (
|
||||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="border-t px-4 py-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Check, ChevronLeft, ChevronRight, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
import { Check, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -22,7 +23,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
|
|
||||||
type StatusFilter = PurchaseOrderStatus | "All"
|
type StatusFilter = PurchaseOrderStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function PurchaseOrdersListPage() {
|
export default function PurchaseOrdersListPage() {
|
||||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||||
@@ -260,31 +261,7 @@ export default function PurchaseOrdersListPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function PurchaseReturnsListPage() {
|
|||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { errorMessage } from "@/lib/error-map"
|
|||||||
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
|
||||||
import { PaginationMeta } from "@/types/common"
|
import { PaginationMeta } from "@/types/common"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -17,7 +18,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
|
|||||||
|
|
||||||
type StatusFilter = RequisitionStatus | "All"
|
type StatusFilter = RequisitionStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function RequisitionsListPage() {
|
export default function RequisitionsListPage() {
|
||||||
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
|
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
|
||||||
@@ -114,36 +115,14 @@ export default function RequisitionsListPage() {
|
|||||||
<RequisitionStatusBadge status={r.status} />
|
<RequisitionStatusBadge status={r.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
|
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
|
<TableCell className="px-3 py-3.5">{r.requestedBy}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export default function ProducedItemsPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -425,22 +426,7 @@ export default function ProductionRunsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-base text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={pagination.page >= pagination.totalPages}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
@@ -286,26 +287,7 @@ export default function ProductionTemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-base text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setPage((p) => p - 1)} disabled={pagination.page <= 1}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,223 +1,193 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
|
import { Plus, Ruler, Tag } from "lucide-react"
|
||||||
|
|
||||||
import { brandsApi } from "@/lib/api/brands"
|
import { brandsApi } from "@/lib/api/brands"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { uomsApi } from "@/lib/api/uoms"
|
||||||
import { validateBrandName } from "@/lib/validations/master-data"
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { Brand, Uom } from "@/types/master-data"
|
||||||
import { Brand } from "@/types/master-data"
|
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
type SortOrder = "asc" | "desc"
|
type Tab = "brand" | "uom"
|
||||||
type SortKey = "brandId" | "name" | "status" | "createdAt"
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
// Large enough to show "all brands" in one grid — the chip layout replaces the
|
||||||
|
// old table's pagination, so we just ask for everything in one page.
|
||||||
|
const ALL_ITEMS_PAGE_SIZE = 500
|
||||||
|
|
||||||
export default function BrandsPage() {
|
export default function BrandsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const initialTab: Tab = searchParams.get("tab") === "uom" ? "uom" : "brand"
|
||||||
|
const [tab, setTab] = useState<Tab>(initialTab)
|
||||||
|
|
||||||
|
function selectTab(next: Tab) {
|
||||||
|
setTab(next)
|
||||||
|
router.replace(next === "uom" ? "/dashboard/products/brands?tab=uom" : "/dashboard/products/brands", { scroll: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Brand/UOM</h1>
|
||||||
|
<p className="text-base text-muted-foreground">Manage product brands and units of measure.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex w-fit items-center gap-1 rounded-full bg-muted p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectTab("brand")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
tab === "brand" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Brand
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectTab("uom")}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
tab === "uom" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
UOM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "brand" ? <BrandPanel /> : <UomPanel />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandPanel() {
|
||||||
const [brands, setBrands] = useState<Brand[] | null>(null)
|
const [brands, setBrands] = useState<Brand[] | null>(null)
|
||||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [searchInput, setSearchInput] = useState("")
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
const [status, setStatus] = useState<StatusFilter>("All")
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
// Sorts only the currently loaded page client-side — the backend ignores `sort` and
|
|
||||||
// always returns Name ascending, so this doesn't hold across page turns or other columns.
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("name")
|
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<Brand | null>(null)
|
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
const [editing, setEditing] = useState<Brand | null>(null)
|
||||||
return () => clearTimeout(timeout)
|
const [editName, setEditName] = useState("")
|
||||||
}, [searchInput])
|
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false)
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
}, [search, status])
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
setError(null)
|
setError(null)
|
||||||
brandsApi
|
brandsApi
|
||||||
.list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
|
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||||
.then((res) => {
|
.then((res) => setBrands(res.items))
|
||||||
setBrands(res.items)
|
|
||||||
setPagination(res.pagination)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(load, [search, status, page])
|
useEffect(load, [])
|
||||||
|
|
||||||
const hasFilters = search.trim().length > 0 || status !== "All"
|
async function handleAdd() {
|
||||||
|
|
||||||
const sortedBrands = brands
|
|
||||||
? [...brands].sort((a, b) => {
|
|
||||||
const cmp =
|
|
||||||
sortKey === "brandId"
|
|
||||||
? a.brandId - b.brandId
|
|
||||||
: sortKey === "createdAt"
|
|
||||||
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
||||||
: a[sortKey].localeCompare(b[sortKey])
|
|
||||||
return sortOrder === "asc" ? cmp : -cmp
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
function toggleSort(key: SortKey) {
|
|
||||||
if (key === sortKey) {
|
|
||||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
|
||||||
} else {
|
|
||||||
setSortKey(key)
|
|
||||||
setSortOrder("asc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDialog() {
|
|
||||||
setEditing(null)
|
|
||||||
setName("")
|
|
||||||
setErrors({})
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDialog(brand: Brand) {
|
|
||||||
setEditing(brand)
|
|
||||||
setName(brand.name)
|
|
||||||
setErrors({})
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
const nextErrors = validateBrandName(name)
|
const nextErrors = validateBrandName(name)
|
||||||
setErrors(nextErrors)
|
setErrors(nextErrors)
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
let result
|
const result = await brandsApi.create({ name })
|
||||||
if (editing) {
|
toast.success("Brand created", result.data.name)
|
||||||
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
|
||||||
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
|
||||||
const current = await brandsApi.get(editing.brandId)
|
|
||||||
result = await brandsApi.update(editing.brandId, { name }, current.etag ?? "")
|
|
||||||
} else {
|
|
||||||
result = await brandsApi.create({ name })
|
|
||||||
}
|
|
||||||
toast.success(editing ? "Brand updated" : "Brand created", result.data.name)
|
|
||||||
setOpen(false)
|
|
||||||
setName("")
|
setName("")
|
||||||
setEditing(null)
|
|
||||||
setErrors({})
|
setErrors({})
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors({ name: errorMessage(err) })
|
setErrors({ name: errorMessage(err) })
|
||||||
toast.error(editing ? "Could not update brand" : "Could not create brand", errorMessage(err))
|
toast.error("Could not create brand", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||||
async function handleToggleStatus(brand: Brand) {
|
async function handleToggleActive(brand: Brand) {
|
||||||
const next = brand.status === "Active" ? "Inactive" : "Active"
|
const next = brand.status === "Active" ? "Inactive" : "Active"
|
||||||
setDeletingId(brand.brandId)
|
setTogglingId(brand.brandId)
|
||||||
try {
|
try {
|
||||||
await brandsApi.updateStatus(brand.brandId, next)
|
await brandsApi.updateStatus(brand.brandId, next)
|
||||||
toast.success(next === "Inactive" ? "Brand deactivated" : "Brand activated", brand.name)
|
|
||||||
load()
|
load()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Could not update brand status", errorMessage(err))
|
toast.error("Could not update brand status", errorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setTogglingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(brand: Brand) {
|
||||||
|
setEditing(brand)
|
||||||
|
setEditName(brand.name)
|
||||||
|
setEditErrors({})
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSubmit() {
|
||||||
|
if (!editing) return
|
||||||
|
const nextErrors = validateBrandName(editName)
|
||||||
|
setEditErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setEditSubmitting(true)
|
||||||
|
try {
|
||||||
|
// The list response carries no ETag, so re-read to get a fresh If-Match token
|
||||||
|
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
|
||||||
|
const current = await brandsApi.get(editing.brandId)
|
||||||
|
const result = await brandsApi.update(editing.brandId, { name: editName }, current.etag ?? "")
|
||||||
|
toast.success("Brand updated", result.data.name)
|
||||||
|
setEditOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
setEditErrors({ name: errorMessage(err) })
|
||||||
|
toast.error("Could not update brand", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Brands</h1>
|
|
||||||
<p className="text-base text-muted-foreground">Manage product brands.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
|
|
||||||
<DialogContent className="sm:max-w-sm">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
|
|
||||||
<DialogDescription>Give the brand a name.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
|
||||||
<FieldLabel htmlFor="brand-name">Name</FieldLabel>
|
|
||||||
<Input id="brand-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Bosch" aria-invalid={!!errors.name} />
|
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
|
|
||||||
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<div className="relative flex-1 basis-0">
|
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
<Input
|
||||||
value={searchInput}
|
value={name}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Search brands…"
|
placeholder="New brand name, e.g. Bosch"
|
||||||
className="h-14 w-full pl-11 text-base"
|
className="h-12 flex-1 text-base"
|
||||||
aria-label="Search brands"
|
aria-label="New brand name"
|
||||||
|
aria-invalid={!!errors.name}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
/>
|
/>
|
||||||
|
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
Add Brand
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||||
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
|
||||||
<SelectItem value="Active" className="text-base">Active</SelectItem>
|
|
||||||
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && brands === null && (
|
{!error && brands === null && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -225,162 +195,171 @@ export default function BrandsPage() {
|
|||||||
{!error && brands !== null && brands.length === 0 && (
|
{!error && brands !== null && brands.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
<Tag className="size-12 text-muted-foreground" />
|
<Tag className="size-12 text-muted-foreground" />
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">No brands yet.</p>
|
||||||
{hasFilters ? "No brands match your search/filter." : "No brands yet."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && brands !== null && brands.length > 0 && (
|
{!error && brands !== null && brands.length > 0 && (
|
||||||
<>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Table className="text-base">
|
{brands.map((b) => (
|
||||||
<TableHeader>
|
<label
|
||||||
<TableRow>
|
key={b.brandId}
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
onDoubleClick={() => openEdit(b)}
|
||||||
<SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
|
title="Tick to activate/deactivate — double-click to rename"
|
||||||
</TableHead>
|
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">
|
|
||||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{sortedBrands!.map((b) => (
|
|
||||||
<TableRow key={b.brandId}>
|
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"border-transparent",
|
|
||||||
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{b.status}
|
<Checkbox
|
||||||
</Badge>
|
checked={b.status === "Active"}
|
||||||
</TableCell>
|
onCheckedChange={() => handleToggleActive(b)}
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
disabled={togglingId === b.brandId}
|
||||||
<TableCell className="px-3 py-3.5">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Edit ${b.name}`}
|
|
||||||
onClick={() => openEditDialog(b)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Deactivate, not delete: the API has no DELETE for any master
|
|
||||||
(FR-MD-08) — records referenced by transactions must survive. */}
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
|
|
||||||
aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
|
|
||||||
disabled={deletingId === b.brandId}
|
|
||||||
/>
|
/>
|
||||||
}
|
<span className="font-medium">{b.name}</span>
|
||||||
>
|
</label>
|
||||||
{b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent
|
|
||||||
variant={b.status === "Active" ? "destructive" : "success"}
|
|
||||||
title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
|
|
||||||
description={
|
|
||||||
b.status === "Active"
|
|
||||||
? "The brand stays on existing items but cannot be assigned to new ones."
|
|
||||||
: "The brand becomes selectable again."
|
|
||||||
}
|
|
||||||
confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
|
|
||||||
onConfirm={() => handleToggleStatus(b)}
|
|
||||||
/>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
)}
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<div className="flex items-center justify-between">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<p className="text-sm text-muted-foreground">
|
<DialogHeader className="items-center text-center">
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
<DialogTitle>Edit brand</DialogTitle>
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
<DialogDescription>Give the brand a name.</DialogDescription>
|
||||||
</p>
|
</DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<FieldGroup>
|
||||||
<Button
|
<Field data-invalid={!!editErrors.name}>
|
||||||
type="button"
|
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
|
||||||
variant="outline"
|
<Input
|
||||||
size="sm"
|
id="brand-edit-name"
|
||||||
disabled={pagination.page <= 1}
|
value={editName}
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
>
|
aria-invalid={!!editErrors.name}
|
||||||
<ChevronLeft />
|
/>
|
||||||
Previous
|
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}>
|
||||||
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted-foreground">
|
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}>
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
)}
|
</Dialog>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableHeader({
|
function UomPanel() {
|
||||||
label,
|
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
||||||
active,
|
const [error, setError] = useState<string | null>(null)
|
||||||
order,
|
|
||||||
onClick,
|
const [name, setName] = useState("")
|
||||||
}: {
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
label: string
|
const [submitting, setSubmitting] = useState(false)
|
||||||
active: boolean
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
order: SortOrder
|
|
||||||
onClick: () => void
|
function load() {
|
||||||
}) {
|
uomsApi
|
||||||
|
.list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE })
|
||||||
|
.then((res) => setUoms(res.items))
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [])
|
||||||
|
|
||||||
|
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
|
||||||
|
async function handleToggleActive(uom: Uom) {
|
||||||
|
const next = uom.status === "Active" ? "Inactive" : "Active"
|
||||||
|
setTogglingId(uom.uomId)
|
||||||
|
try {
|
||||||
|
await uomsApi.updateStatus(uom.uomId, next)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update UOM status", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setTogglingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const nextErrors = validateUomName(name)
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const uom = await uomsApi.create({ name })
|
||||||
|
toast.success("UOM created", uom.name)
|
||||||
|
setName("")
|
||||||
|
setErrors({})
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe?.name) setErrors({ name: fe.name })
|
||||||
|
toast.error("Could not create UOM", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex flex-col gap-6">
|
||||||
type="button"
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
className="flex items-center gap-1 hover:text-foreground"
|
<Input
|
||||||
onClick={onClick}
|
value={name}
|
||||||
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
|
onChange={(e) => setName(e.target.value)}
|
||||||
>
|
placeholder="New UOM name, e.g. Box-12"
|
||||||
{label}
|
className="h-12 flex-1 text-base"
|
||||||
{active ? (
|
aria-label="New UOM name"
|
||||||
order === "asc" ? (
|
aria-invalid={!!errors.name}
|
||||||
<ArrowUp className="size-3.5" />
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
) : (
|
/>
|
||||||
<ArrowDown className="size-3.5" />
|
<Button size="lg" onClick={handleAdd} disabled={submitting}>
|
||||||
)
|
<Plus className="size-5" />
|
||||||
) : (
|
Add UOM
|
||||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
|
{!error && uoms === null && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && uoms !== null && uoms.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<Ruler className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && uoms !== null && uoms.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{uoms.map((u) => (
|
||||||
|
<label
|
||||||
|
key={u.uomId}
|
||||||
|
title="Tick to activate/deactivate"
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={u.status === "Active"}
|
||||||
|
onCheckedChange={() => handleToggleActive(u)}
|
||||||
|
disabled={togglingId === u.uomId}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{u.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export default function CategorySubCategoriesPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{subCategories.map((s) => (
|
{subCategories.map((s) => (
|
||||||
<TableRow key={s.subCategoryId}>
|
<TableRow key={s.subCategoryId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
<TableCell className="px-3 py-3.5">{s.subCategoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -192,7 +192,7 @@ export default function CategorySubCategoriesPage() {
|
|||||||
{s.status}
|
{s.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
|
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${s.name}`} onClick={() => openEditDialog(s)}>
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ListTree, Network, Pencil, Plus, Search } from "lucide-react"
|
||||||
|
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
|
import { productConfig } from "@/lib/api/product-config"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||||
import { Category } from "@/types/master-data"
|
import { Category, ProductConfig } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -17,6 +18,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -26,7 +28,7 @@ type SortOrder = "asc" | "desc"
|
|||||||
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
type SortKey = "categoryId" | "name" | "status" | "createdAt"
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState<Category[] | null>(null)
|
const [categories, setCategories] = useState<Category[] | null>(null)
|
||||||
@@ -48,6 +50,36 @@ export default function CategoriesPage() {
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
const [config, setConfig] = useState<ProductConfig | null>(null)
|
||||||
|
const [subCategoryCounts, setSubCategoryCounts] = useState<Record<number, number>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productConfig().then(setConfig).catch(() => setConfig(null))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subcategories are their own resource — there's no count on the Category DTO itself,
|
||||||
|
// so fetch each visible row's total separately (pageSize 1: only `pagination.totalItems`
|
||||||
|
// is needed) once the feature is on and the page's categories are known.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config?.subcategoriesEnabled || !categories || categories.length === 0) {
|
||||||
|
setSubCategoryCounts({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
Promise.all(
|
||||||
|
categories.map((c) =>
|
||||||
|
categoriesApi
|
||||||
|
.listSubCategories(c.categoryId, { pageSize: 1 })
|
||||||
|
.then((res) => [c.categoryId, res.pagination.totalItems] as const)
|
||||||
|
.catch(() => [c.categoryId, 0] as const)
|
||||||
|
)
|
||||||
|
).then((entries) => {
|
||||||
|
if (!cancelled) setSubCategoryCounts(Object.fromEntries(entries))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [config?.subcategoriesEnabled, categories])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
|
||||||
@@ -247,13 +279,14 @@ export default function CategoriesPage() {
|
|||||||
<TableHead className="h-12 px-3 text-sm">
|
<TableHead className="h-12 px-3 text-sm">
|
||||||
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
<SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategories</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedCategories!.map((c) => (
|
{sortedCategories!.map((c) => (
|
||||||
<TableRow key={c.categoryId}>
|
<TableRow key={c.categoryId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
<TableCell className="px-3 py-3.5">{c.categoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -266,10 +299,15 @@ export default function CategoriesPage() {
|
|||||||
{c.status}
|
{c.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
{config?.subcategoriesEnabled && (
|
||||||
|
<TableCell className="px-3 py-3.5">{subCategoryCounts[c.categoryId] ?? "—"}</TableCell>
|
||||||
|
)}
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Subcategories are their own resource now, not a nested tree. */}
|
{/* Subcategories are their own resource now, not a nested tree.
|
||||||
|
Hidden while the feature is switched off in Product Configuration. */}
|
||||||
|
{config?.subcategoriesEnabled && (
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/products/categories/${c.categoryId}`}
|
href={`/dashboard/products/categories/${c.categoryId}`}
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
@@ -277,6 +315,7 @@ export default function CategoriesPage() {
|
|||||||
>
|
>
|
||||||
<Network className="size-4" />
|
<Network className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -321,39 +360,7 @@ export default function CategoriesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page <= 1}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default function ItemTypesPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{itemTypes.map((t) => (
|
{itemTypes.map((t) => (
|
||||||
<TableRow key={t.itemTypeId}>
|
<TableRow key={t.itemTypeId}>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.itemTypeId}</TableCell>
|
<TableCell className="px-3 py-3.5">{t.itemTypeId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
|
<Badge variant="outline">{t.isMeasurable ? "Measurement" : "Text"}</Badge>
|
||||||
@@ -215,7 +215,7 @@ export default function ItemTypesPage() {
|
|||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
|
<Badge variant={t.status === "Active" ? "default" : "secondary"}>{t.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(t.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
|
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${t.name}`} onClick={() => openEditDialog(t)}>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { FieldError } from "@/components/ui/field"
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -720,9 +721,10 @@ export default function NewItemPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
{config?.sellingPricesEnabled && (
|
{config?.sellingPricesEnabled && (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
<div className="flex flex-col gap-4 rounded-xl border p-5">
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
<h2 className="text-lg font-semibold text-foreground">Variant selling prices</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -730,32 +732,23 @@ export default function NewItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-flex w-fit rounded-lg border p-1">
|
<RadioGroup
|
||||||
<button
|
value={priceMode}
|
||||||
type="button"
|
onValueChange={(v) => setPriceMode(v as typeof priceMode)}
|
||||||
onClick={() => setPriceMode("stock")}
|
className="flex flex-col gap-3"
|
||||||
className={cn(
|
|
||||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
|
||||||
priceMode === "stock" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Use stock value
|
<label className="flex items-center gap-2.5">
|
||||||
</button>
|
<RadioGroupItem value="stock" />
|
||||||
<button
|
<span className="text-base font-medium">Use stock value</span>
|
||||||
type="button"
|
</label>
|
||||||
onClick={() => setPriceMode("fixed")}
|
<label className="flex items-center gap-2.5">
|
||||||
className={cn(
|
<RadioGroupItem value="fixed" />
|
||||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
<span className="text-base font-medium">Fixed price</span>
|
||||||
priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
</label>
|
||||||
)}
|
</RadioGroup>
|
||||||
>
|
|
||||||
Fixed price
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{priceMode === "fixed" && (
|
{priceMode === "fixed" && (
|
||||||
<div className="flex max-w-xl flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
<div className="flex min-w-56 flex-1 flex-col gap-2">
|
||||||
<Label className="text-base">Bulk default flat price</Label>
|
<Label className="text-base">Bulk default flat price</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -805,35 +798,27 @@ export default function NewItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-flex w-fit rounded-lg border p-1">
|
<RadioGroup
|
||||||
<button
|
value={warranty}
|
||||||
type="button"
|
onValueChange={(v) => {
|
||||||
onClick={() => {
|
setWarranty(v as typeof warranty)
|
||||||
setWarranty("NonWarranty")
|
if (v === "NonWarranty") setWarrantyPeriodMonths(null)
|
||||||
setWarrantyPeriodMonths(null)
|
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="flex flex-col gap-3"
|
||||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
|
||||||
warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Non-Warranty
|
<label className="flex items-center gap-2.5">
|
||||||
</button>
|
<RadioGroupItem value="NonWarranty" />
|
||||||
<button
|
<span className="text-base font-medium">Non-Warranty</span>
|
||||||
type="button"
|
</label>
|
||||||
onClick={() => setWarranty("Warranty")}
|
<label className="flex items-center gap-2.5">
|
||||||
className={cn(
|
<RadioGroupItem value="Warranty" />
|
||||||
"rounded-md px-4 py-2 text-base font-medium transition-colors",
|
<span className="text-base font-medium">Warranty</span>
|
||||||
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
|
</label>
|
||||||
)}
|
</RadioGroup>
|
||||||
>
|
|
||||||
Warranty
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{warranty === "Warranty" && (
|
{warranty === "Warranty" && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="mt-2 flex flex-col gap-1.5">
|
||||||
<Label>Warranty period</Label>
|
<Label>Warranty period +</Label>
|
||||||
<Select<WarrantyPeriodMonths>
|
<Select<WarrantyPeriodMonths>
|
||||||
value={warrantyPeriodMonths}
|
value={warrantyPeriodMonths}
|
||||||
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
|
||||||
@@ -854,6 +839,8 @@ export default function NewItemPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
{/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no
|
||||||
item-type reference), so this section IS the enforcement. */}
|
item-type reference), so this section IS the enforcement. */}
|
||||||
@@ -888,7 +875,7 @@ export default function NewItemPage() {
|
|||||||
key={t.itemTypeId}
|
key={t.itemTypeId}
|
||||||
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
title={blocked ? "Only one measurement dimension can be used at a time." : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2.5 rounded-lg border px-3 py-2",
|
"flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2",
|
||||||
blocked ? "opacity-50" : "hover:bg-muted/50"
|
blocked ? "opacity-50" : "hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1047,7 +1034,7 @@ export default function NewItemPage() {
|
|||||||
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
<TableCell className="py-2.5 pr-1 pl-3 font-medium">{variant.sku}</TableCell>
|
||||||
{/* Read-only: content is derived, so an editable cell here could only
|
{/* Read-only: content is derived, so an editable cell here could only
|
||||||
disagree with the value that produced it. */}
|
disagree with the value that produced it. */}
|
||||||
<TableCell className="px-3 py-2.5 text-muted-foreground">
|
<TableCell className="px-3 py-2.5">
|
||||||
{contentLabelFor(variant)}
|
{contentLabelFor(variant)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{priceMode === "fixed" && (
|
{priceMode === "fixed" && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
|
import { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react"
|
||||||
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
import { categoriesApi } from "@/lib/api/categories"
|
import { categoriesApi } from "@/lib/api/categories"
|
||||||
@@ -14,8 +14,9 @@ import { EntityStatus, PaginationMeta } from "@/types/common"
|
|||||||
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
|
import { Brand, Category, ItemListItem, ProductConfig, SubCategory, TrackingMode } from "@/types/master-data"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -23,7 +24,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
type TrackingFilter = TrackingMode | "All"
|
type TrackingFilter = TrackingMode | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function ItemsPage() {
|
export default function ItemsPage() {
|
||||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||||
@@ -103,7 +104,6 @@ export default function ItemsPage() {
|
|||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
<h1 className="text-2xl font-bold text-foreground">Items</h1>
|
||||||
<p className="text-base text-muted-foreground">Item master — SKU, tracking mode, category, default vendor (FR-MD-01).</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
<Link href="/dashboard/products/uoms" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||||
@@ -208,7 +208,6 @@ export default function ItemsPage() {
|
|||||||
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
|
{config?.subcategoriesEnabled && <TableHead className="h-12 px-3 text-sm">Subcategory</TableHead>}
|
||||||
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
|
{config?.brandsEnabled && <TableHead className="h-12 px-3 text-sm">Brand</TableHead>}
|
||||||
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Tracking</TableHead>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -224,17 +223,16 @@ export default function ItemsPage() {
|
|||||||
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
<TableCell className="px-3 py-3.5">{item.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
<TableCell className="px-3 py-3.5">{categoryName(item.categoryId)}</TableCell>
|
||||||
{config?.subcategoriesEnabled && (
|
{config?.subcategoriesEnabled && (
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
|
{item.subCategoryId !== null ? subCategoryName(item.subCategoryId) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{config?.brandsEnabled && (
|
{config?.brandsEnabled && (
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{item.brandId !== null ? brandName(item.brandId) : "—"}
|
{item.brandId !== null ? brandName(item.brandId) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
|
<TableCell className="px-3 py-3.5">{item.stockNature}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{item.trackingMode}</TableCell>
|
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -260,31 +258,7 @@ export default function ItemsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,129 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect } from "react"
|
||||||
import { Plus, Ruler } from "lucide-react"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only
|
||||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
// to keep old links/bookmarks working.
|
||||||
import { validateUomName } from "@/lib/validations/master-data"
|
export default function UomsRedirectPage() {
|
||||||
import { Uom } from "@/types/master-data"
|
const router = useRouter()
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
useEffect(() => {
|
||||||
import { Input } from "@/components/ui/input"
|
router.replace("/dashboard/products/brands?tab=uom")
|
||||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
}, [router])
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { toast } from "@/components/ui/toast"
|
|
||||||
|
|
||||||
export default function UomsPage() {
|
return null
|
||||||
const [uoms, setUoms] = useState<Uom[] | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(load, [])
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const nextErrors = validateUomName(name)
|
|
||||||
setErrors(nextErrors)
|
|
||||||
if (Object.keys(nextErrors).length > 0) return
|
|
||||||
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
const uom = await uomsApi.create({ name })
|
|
||||||
toast.success("UOM created", uom.name)
|
|
||||||
setOpen(false)
|
|
||||||
setName("")
|
|
||||||
setErrors({})
|
|
||||||
load()
|
|
||||||
} catch (err) {
|
|
||||||
const fe = fieldErrors(err)
|
|
||||||
if (fe?.name) setErrors({ name: fe.name })
|
|
||||||
toast.error("Could not create UOM", errorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
|
|
||||||
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and as work-in-progress labels in production (FR-MD-02).</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger render={<Button size="lg"><Plus className="size-5" />New UOM</Button>} />
|
|
||||||
<DialogContent className="sm:max-w-sm">
|
|
||||||
<DialogHeader className="items-center text-center">
|
|
||||||
<DialogTitle>New UOM</DialogTitle>
|
|
||||||
<DialogDescription>e.g. EA, KG, Box-12.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<FieldGroup>
|
|
||||||
<Field data-invalid={!!errors.name}>
|
|
||||||
<FieldLabel htmlFor="uom-name">Name</FieldLabel>
|
|
||||||
<Input id="uom-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} />
|
|
||||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
|
|
||||||
{submitting ? "Creating…" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && uoms === null && (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-14 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && uoms !== null && uoms.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
|
||||||
<Ruler className="size-12 text-muted-foreground" />
|
|
||||||
<p className="text-base text-muted-foreground">No UOMs yet.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!error && uoms !== null && uoms.length > 0 && (
|
|
||||||
<Table className="text-base">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{uoms.map((u) => (
|
|
||||||
<TableRow key={u.uomId}>
|
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{u.name}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1035,7 +1035,7 @@ export default function NewGrnPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{Array.from({ length: units }, (_, i) => (
|
{Array.from({ length: units }, (_, i) => (
|
||||||
<TableRow key={`${line.key}-${i}`}>
|
<TableRow key={`${line.key}-${i}`}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
|
<TableCell className="px-3 py-3 align-top text-sm">
|
||||||
{i + 1} of {units}
|
{i + 1} of {units}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "lucide-react"
|
import { Eye, PackageSearch, Plus, Search } from "lucide-react"
|
||||||
|
|
||||||
import { grnsApi } from "@/lib/api/grns"
|
import { grnsApi } from "@/lib/api/grns"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +28,7 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
|||||||
|
|
||||||
type StatusFilter = GrnStatus | "All"
|
type StatusFilter = GrnStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function GrnListPage() {
|
export default function GrnListPage() {
|
||||||
const { activeBranchId, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
@@ -234,39 +235,7 @@ export default function GrnListPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page <= 1}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export default function BundleTemplatesPage() {
|
|||||||
placeholder="Search item…"
|
placeholder="Search item…"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
|
<TableCell className="px-4 py-2 min-w-40 text-sm">
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
@@ -288,7 +288,7 @@ export default function BundleTemplatesPage() {
|
|||||||
<div className="font-medium">{t.templateName}</div>
|
<div className="font-medium">{t.templateName}</div>
|
||||||
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
|
<div className="text-xs text-muted-foreground">{t.templateCode}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{t.description ?? "—"}</TableCell>
|
<TableCell className="px-4 py-3.5">{t.description ?? "—"}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{t.lineCount}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
|
<TableCell className="px-4 py-3.5">{t.status}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right">
|
<TableCell className="px-4 py-3.5 text-right">
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export default function NewFreeIssuePage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{activeLines.map((line, idx) => (
|
{activeLines.map((line, idx) => (
|
||||||
<TableRow key={line.key} className="align-middle">
|
<TableRow key={line.key} className="align-middle">
|
||||||
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-80">
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
<Combobox
|
<Combobox
|
||||||
items={itemOptions}
|
items={itemOptions}
|
||||||
@@ -315,7 +315,7 @@ export default function NewFreeIssuePage() {
|
|||||||
placeholder="Search item…"
|
placeholder="Search item…"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
|
<TableCell className="px-4 py-2 min-w-44 text-sm">
|
||||||
{baseUomLabel(items, uoms, line.itemId)}
|
{baseUomLabel(items, uoms, line.itemId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
@@ -324,7 +324,7 @@ export default function NewFreeIssuePage() {
|
|||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
<Input type="number" min="0" step="1" value={line.freeQty} onChange={(e) => updateEditingLine(line.key, { freeQty: Number(e.target.value) })} className="h-9 w-24 text-right font-mono text-sm tabular-nums" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">editing</TableCell>
|
<TableCell className="px-4 py-2 text-right text-xs">editing</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -365,7 +365,7 @@ export default function NewFreeIssuePage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{lines.map((line, idx) => (
|
{lines.map((line, idx) => (
|
||||||
<TableRow key={line.key} className="align-middle">
|
<TableRow key={line.key} className="align-middle">
|
||||||
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
|
<TableCell className="px-4 py-2 text-xs">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-4 py-2 min-w-80">
|
<TableCell className="px-4 py-2 min-w-80">
|
||||||
<Combobox
|
<Combobox
|
||||||
items={itemOptions}
|
items={itemOptions}
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ function NewSalesInvoiceContent() {
|
|||||||
const bundleQty = row.lines[0]?.bundleQty ?? 1
|
const bundleQty = row.lines[0]?.bundleQty ?? 1
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.groupId}>
|
<TableRow key={row.groupId}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<div className="font-medium">Bundle: {row.bundleName}</div>
|
<div className="font-medium">Bundle: {row.bundleName}</div>
|
||||||
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
||||||
@@ -555,7 +555,7 @@ function NewSalesInvoiceContent() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm">—</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -568,11 +568,11 @@ function NewSalesInvoiceContent() {
|
|||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
|
<p className="mt-1 text-xs text-muted-foreground">bundles</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">
|
<TableCell className="px-3 py-3 align-top text-right text-sm">
|
||||||
{bundleFreeQty || "—"}
|
{bundleFreeQty || "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">—</TableCell>
|
<TableCell className="px-3 py-3 align-top text-right text-sm">—</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||||
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
|
<div className="flex h-11 items-center justify-end font-semibold">{lkr.format(bundleTotal)}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -598,7 +598,7 @@ function NewSalesInvoiceContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{idx + 1}</TableCell>
|
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Combobox
|
<Combobox
|
||||||
items={lineComboOptions}
|
items={lineComboOptions}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
import { CheckCircle2, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
@@ -20,7 +21,7 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type StatusFilter = SalesInvoiceStatus | "All"
|
type StatusFilter = SalesInvoiceStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||||
{ value: "All", label: "All" },
|
{ value: "All", label: "All" },
|
||||||
{ value: "Draft", label: "Draft" },
|
{ value: "Draft", label: "Draft" },
|
||||||
@@ -205,11 +206,11 @@ export default function SalesInvoicesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
<TableCell className="px-4 py-3.5">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right tabular-nums">-{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-4 py-3.5">
|
<TableCell className="px-4 py-3.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
@@ -279,22 +280,9 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && (
|
||||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="border-t px-4 py-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function SalesReturnsListPage() {
|
|||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{r.glJournalNo ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ import { ManagedUser } from "@/types/users"
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { StatCard } from "@/components/ui/stat-card"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function AdjustmentsListPage() {
|
|||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<AdjustmentStatusBadge status={a.status} />
|
<AdjustmentStatusBadge status={a.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{a.glJournalNo ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{a.glJournalNo ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(a.createdAt).toLocaleString()}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function CountDetailPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{count.lines.map((line) => (
|
{count.lines.map((line) => (
|
||||||
<TableRow key={line.countLineId}>
|
<TableRow key={line.countLineId}>
|
||||||
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
{count.status === "Draft" ? (
|
{count.status === "Draft" ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
|
import { ScrollText } from "lucide-react"
|
||||||
|
|
||||||
import { stockApi } from "@/lib/api/stock"
|
import { stockApi } from "@/lib/api/stock"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -13,15 +13,15 @@ import { PaginationMeta } from "@/types/common"
|
|||||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||||
|
|
||||||
import { useAuth } from "@/components/auth/AuthProvider"
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function StockLedgerPage() {
|
export default function StockLedgerPage() {
|
||||||
const { activeBranchId, branchingEnabled } = useAuth()
|
const { activeBranchId, branchingEnabled } = useAuth()
|
||||||
@@ -197,7 +197,7 @@ export default function StockLedgerPage() {
|
|||||||
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{entry.unitCost.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{entry.value.toFixed(2)}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{entry.runningBalance}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
<TableCell className="px-3 py-3.5">
|
||||||
{entry.sourceDocType} #{entry.sourceDocId}
|
{entry.sourceDocType} #{entry.sourceDocId}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -206,33 +206,7 @@ export default function StockLedgerPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function TransferDetailPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{transfer.lines.map((line) => (
|
{transfer.lines.map((line) => (
|
||||||
<TableRow key={line.transferLineId}>
|
<TableRow key={line.transferLineId}>
|
||||||
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.srcBinId ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.destBinId ?? "—"}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
<TableCell className="px-3 py-3.5">{line.qty}</TableCell>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function ValuationContent() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{valuation.layers.map((layer) => (
|
{valuation.layers.map((layer) => (
|
||||||
<TableRow key={layer.layerId}>
|
<TableRow key={layer.layerId}>
|
||||||
<TableCell className="px-3 py-3.5">#{layer.layerId}</TableCell>
|
<TableCell className="px-3 py-3.5">{layer.layerId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
|
<TableCell className="px-3 py-3.5">{new Date(layer.receiptDate).toLocaleString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell>
|
<TableCell className="px-3 py-3.5">{layer.qtyRemaining}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell>
|
<TableCell className="px-3 py-3.5">{layer.unitCost.toFixed(2)}</TableCell>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
|
||||||
|
|
||||||
import { vendorsApi } from "@/lib/api/vendors"
|
import { vendorsApi } from "@/lib/api/vendors"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -32,7 +33,7 @@ import { toast } from "@/components/ui/toast"
|
|||||||
|
|
||||||
type StatusFilter = EntityStatus | "All"
|
type StatusFilter = EntityStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 5
|
const PAGE_SIZE = 25
|
||||||
|
|
||||||
export default function VendorsPage() {
|
export default function VendorsPage() {
|
||||||
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
const [vendors, setVendors] = useState<Vendor[] | null>(null)
|
||||||
@@ -353,39 +354,7 @@ export default function VendorsPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
|
||||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page <= 1}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={pagination.page >= pagination.totalPages}
|
|
||||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,10 +59,11 @@
|
|||||||
--card-foreground: oklch(0.15 0 0);
|
--card-foreground: oklch(0.15 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.15 0 0);
|
--popover-foreground: oklch(0.15 0 0);
|
||||||
--primary: oklch(0.463 0.258 262.881);
|
|
||||||
|
--primary: oklch(0.586 0.222 17.585);
|
||||||
--primary-foreground: oklch(0.98 0 0);
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
--secondary: oklch(0.92 0.055 264.901);
|
--secondary: oklch(0.969 0.015 12.422);
|
||||||
--secondary-foreground: oklch(0.463 0.258 262.881);
|
--secondary-foreground: oklch(0.514 0.198 16.935);
|
||||||
--muted: oklch(0.93 0 0);
|
--muted: oklch(0.93 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.55 0.15 205.5);
|
--accent: oklch(0.55 0.15 205.5);
|
||||||
@@ -70,12 +71,12 @@
|
|||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.925 0.01 0);
|
--border: oklch(0.925 0.01 0);
|
||||||
--input: oklch(0.945 0.01 0);
|
--input: oklch(0.945 0.01 0);
|
||||||
--ring: oklch(0.463 0.258 262.881);
|
--ring: oklch(0.586 0.222 17.585);
|
||||||
--success: oklch(0.582 0.183 142.495);
|
--success: oklch(0.582 0.183 142.495);
|
||||||
--warning: oklch(0.787 0.141 72.031);
|
--warning: oklch(0.787 0.141 72.031);
|
||||||
--error: oklch(0.577 0.245 27.325);
|
--error: oklch(0.577 0.245 27.325);
|
||||||
--info: oklch(0.631 0.215 259.262);
|
--info: oklch(0.631 0.215 259.262);
|
||||||
--chart-1: oklch(0.463 0.258 262.881);
|
--chart-1: oklch(0.586 0.222 17.585);
|
||||||
--chart-2: oklch(0.631 0.215 259.262);
|
--chart-2: oklch(0.631 0.215 259.262);
|
||||||
--chart-3: oklch(0.582 0.183 142.495);
|
--chart-3: oklch(0.582 0.183 142.495);
|
||||||
--chart-4: oklch(0.787 0.141 72.031);
|
--chart-4: oklch(0.787 0.141 72.031);
|
||||||
@@ -83,12 +84,12 @@
|
|||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: oklch(0.98 0 0);
|
--sidebar: oklch(0.98 0 0);
|
||||||
--sidebar-foreground: oklch(0.15 0 0);
|
--sidebar-foreground: oklch(0.15 0 0);
|
||||||
--sidebar-primary: oklch(0.463 0.258 262.881);
|
--sidebar-primary: oklch(0.586 0.222 17.585);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||||
--sidebar-accent: oklch(0.92 0.055 264.901);
|
--sidebar-accent: oklch(0.969 0.015 12.422);
|
||||||
--sidebar-accent-foreground: oklch(0.463 0.258 262.881);
|
--sidebar-accent-foreground: oklch(0.514 0.198 16.935);
|
||||||
--sidebar-border: oklch(0.925 0.01 0);
|
--sidebar-border: oklch(0.925 0.01 0);
|
||||||
--sidebar-ring: oklch(0.463 0.258 262.881);
|
--sidebar-ring: oklch(0.586 0.222 17.585);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -98,9 +99,9 @@
|
|||||||
--card-foreground: oklch(0.95 0 0);
|
--card-foreground: oklch(0.95 0 0);
|
||||||
--popover: oklch(0.175 0 0);
|
--popover: oklch(0.175 0 0);
|
||||||
--popover-foreground: oklch(0.95 0 0);
|
--popover-foreground: oklch(0.95 0 0);
|
||||||
--primary: oklch(0.68 0.186 265.215);
|
--primary: oklch(0.645 0.215 16.439);
|
||||||
--primary-foreground: oklch(0.125 0 0);
|
--primary-foreground: oklch(0.125 0 0);
|
||||||
--secondary: oklch(0.28 0.04 264.901);
|
--secondary: oklch(0.28 0.06 16.439);
|
||||||
--secondary-foreground: oklch(0.95 0 0);
|
--secondary-foreground: oklch(0.95 0 0);
|
||||||
--muted: oklch(0.275 0.02 0);
|
--muted: oklch(0.275 0.02 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
@@ -109,24 +110,24 @@
|
|||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(0.25 0.01 0);
|
--border: oklch(0.25 0.01 0);
|
||||||
--input: oklch(0.2 0.02 0);
|
--input: oklch(0.2 0.02 0);
|
||||||
--ring: oklch(0.68 0.186 265.215);
|
--ring: oklch(0.645 0.215 16.439);
|
||||||
--success: oklch(0.65 0.15 142.495);
|
--success: oklch(0.65 0.15 142.495);
|
||||||
--warning: oklch(0.85 0.12 72.031);
|
--warning: oklch(0.85 0.12 72.031);
|
||||||
--error: oklch(0.704 0.191 22.216);
|
--error: oklch(0.704 0.191 22.216);
|
||||||
--info: oklch(0.75 0.18 259.262);
|
--info: oklch(0.75 0.18 259.262);
|
||||||
--chart-1: oklch(0.68 0.186 265.215);
|
--chart-1: oklch(0.645 0.215 16.439);
|
||||||
--chart-2: oklch(0.75 0.18 259.262);
|
--chart-2: oklch(0.75 0.18 259.262);
|
||||||
--chart-3: oklch(0.65 0.15 142.495);
|
--chart-3: oklch(0.65 0.15 142.495);
|
||||||
--chart-4: oklch(0.85 0.12 72.031);
|
--chart-4: oklch(0.85 0.12 72.031);
|
||||||
--chart-5: oklch(0.704 0.191 22.216);
|
--chart-5: oklch(0.704 0.191 22.216);
|
||||||
--sidebar: oklch(0.125 0 0);
|
--sidebar: oklch(0.125 0 0);
|
||||||
--sidebar-foreground: oklch(0.95 0 0);
|
--sidebar-foreground: oklch(0.95 0 0);
|
||||||
--sidebar-primary: oklch(0.68 0.186 265.215);
|
--sidebar-primary: oklch(0.645 0.215 16.439);
|
||||||
--sidebar-primary-foreground: oklch(0.95 0 0);
|
--sidebar-primary-foreground: oklch(0.95 0 0);
|
||||||
--sidebar-accent: oklch(0.28 0.04 264.901);
|
--sidebar-accent: oklch(0.28 0.06 16.439);
|
||||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||||
--sidebar-border: oklch(0.25 0.01 0);
|
--sidebar-border: oklch(0.25 0.01 0);
|
||||||
--sidebar-ring: oklch(0.68 0.186 265.215);
|
--sidebar-ring: oklch(0.645 0.215 16.439);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vibrant — the "System" toggle option. Light content area (background,
|
/* Vibrant — the "System" toggle option. Light content area (background,
|
||||||
@@ -141,10 +142,10 @@
|
|||||||
--card-foreground: oklch(0.2 0.02 265);
|
--card-foreground: oklch(0.2 0.02 265);
|
||||||
--popover: oklch(0.995 0.002 265);
|
--popover: oklch(0.995 0.002 265);
|
||||||
--popover-foreground: oklch(0.2 0.02 265);
|
--popover-foreground: oklch(0.2 0.02 265);
|
||||||
--primary: oklch(0.55 0.2 275);
|
--primary: oklch(0.586 0.222 17.585);
|
||||||
--primary-foreground: oklch(0.98 0 0);
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
--secondary: oklch(0.93 0.02 275);
|
--secondary: oklch(0.955 0.02 17.585);
|
||||||
--secondary-foreground: oklch(0.32 0.15 275);
|
--secondary-foreground: oklch(0.514 0.198 16.935);
|
||||||
--muted: oklch(0.94 0.006 265);
|
--muted: oklch(0.94 0.006 265);
|
||||||
--muted-foreground: oklch(0.48 0.02 265);
|
--muted-foreground: oklch(0.48 0.02 265);
|
||||||
--accent: oklch(0.55 0.14 210);
|
--accent: oklch(0.55 0.14 210);
|
||||||
@@ -152,24 +153,24 @@
|
|||||||
--destructive: oklch(0.58 0.22 25);
|
--destructive: oklch(0.58 0.22 25);
|
||||||
--border: oklch(0.88 0.012 265);
|
--border: oklch(0.88 0.012 265);
|
||||||
--input: oklch(0.92 0.01 265);
|
--input: oklch(0.92 0.01 265);
|
||||||
--ring: oklch(0.55 0.2 275);
|
--ring: oklch(0.586 0.222 17.585);
|
||||||
--success: oklch(0.55 0.15 150);
|
--success: oklch(0.55 0.15 150);
|
||||||
--warning: oklch(0.72 0.15 80);
|
--warning: oklch(0.72 0.15 80);
|
||||||
--error: oklch(0.58 0.22 25);
|
--error: oklch(0.58 0.22 25);
|
||||||
--info: oklch(0.55 0.14 210);
|
--info: oklch(0.55 0.14 210);
|
||||||
--chart-1: oklch(0.55 0.2 275);
|
--chart-1: oklch(0.586 0.222 17.585);
|
||||||
--chart-2: oklch(0.55 0.14 210);
|
--chart-2: oklch(0.55 0.14 210);
|
||||||
--chart-3: oklch(0.55 0.15 150);
|
--chart-3: oklch(0.55 0.15 150);
|
||||||
--chart-4: oklch(0.72 0.15 80);
|
--chart-4: oklch(0.72 0.15 80);
|
||||||
--chart-5: oklch(0.58 0.22 25);
|
--chart-5: oklch(0.58 0.22 25);
|
||||||
--sidebar: oklch(0.18 0.02 265);
|
--sidebar: oklch(0.18 0.02 265);
|
||||||
--sidebar-foreground: oklch(0.96 0.005 265);
|
--sidebar-foreground: oklch(0.96 0.005 265);
|
||||||
--sidebar-primary: oklch(0.64 0.19 275);
|
--sidebar-primary: oklch(0.645 0.215 16.439);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||||
--sidebar-accent: oklch(0.28 0.03 265);
|
--sidebar-accent: oklch(0.28 0.03 265);
|
||||||
--sidebar-accent-foreground: oklch(0.96 0.005 265);
|
--sidebar-accent-foreground: oklch(0.96 0.005 265);
|
||||||
--sidebar-border: oklch(0.26 0.025 265);
|
--sidebar-border: oklch(0.26 0.025 265);
|
||||||
--sidebar-ring: oklch(0.64 0.19 275);
|
--sidebar-ring: oklch(0.645 0.215 16.439);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
|
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-bold text-foreground">Welcome back</h1>
|
||||||
<p className="mt-3 text-base text-muted-foreground">Access your ERP dashboard and manage your business</p>
|
<p className="mt-3 text-base text-muted-foreground">Access your ERP dashboard and manage your business</p>
|
||||||
{sessionNotice ? (
|
{sessionNotice ? (
|
||||||
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
@@ -146,7 +147,7 @@ function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
<FieldError errors={[form.formState.errors.password]} />
|
<FieldError errors={[form.formState.errors.password]} />
|
||||||
<div className="flex justify-end mt-1">
|
<div className="flex justify-end mt-1">
|
||||||
<Link href="/login/forgot" className="text-sm font-medium text-foreground hover:text-foreground/70 transition-colors">
|
<Link href="/login/forgot" className="text-sm font-medium text-primary hover:text-primary/80 transition-colors">
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +162,7 @@ function LoginForm() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
|
className="w-full mt-8 h-12 text-base font-semibold rounded-full"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? "Signing in…" : "Sign in"}
|
{form.formState.isSubmitting ? "Signing in…" : "Sign in"}
|
||||||
@@ -176,7 +177,7 @@ function LoginForm() {
|
|||||||
|
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Don't have an account? <Link href="#" className="font-semibold text-foreground hover:text-foreground/80 transition-colors">Create an account</Link>
|
Don't have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Receipt,
|
Receipt,
|
||||||
ReceiptText,
|
ReceiptText,
|
||||||
Ruler,
|
|
||||||
Scale,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -77,7 +76,15 @@ const navItems: {
|
|||||||
expandOnly?: boolean
|
expandOnly?: boolean
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
chevron?: boolean
|
chevron?: boolean
|
||||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
children?: {
|
||||||
|
title: string
|
||||||
|
code: string
|
||||||
|
href: string
|
||||||
|
icon: LucideIcon
|
||||||
|
/** Extra codes that also grant visibility for this row (e.g. a merged Brand/UOM
|
||||||
|
* tab page stays visible to a role that only has the UOM permission). */
|
||||||
|
altCodes?: string[]
|
||||||
|
}[]
|
||||||
}[] = [
|
}[] = [
|
||||||
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
|
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||||
{
|
{
|
||||||
@@ -89,8 +96,7 @@ const navItems: {
|
|||||||
children: [
|
children: [
|
||||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
|
||||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -444,10 +450,17 @@ export function AppSidebar() {
|
|||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
.filter((item) => bypassCodes.has(item.code) || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
bypassCodes.has(item.code) ||
|
||||||
|
navCodes.includes(item.code) ||
|
||||||
|
item.children?.some((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||||
|
)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
|
children: bypassCodes.has(item.code)
|
||||||
|
? item.children
|
||||||
|
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
|
|||||||
@@ -112,10 +112,9 @@ function titleFromPath(pathname: string) {
|
|||||||
|
|
||||||
if (pathname === "/dashboard/products") return "Items"
|
if (pathname === "/dashboard/products") return "Items"
|
||||||
if (pathname === "/dashboard/products/new") return "New Item"
|
if (pathname === "/dashboard/products/new") return "New Item"
|
||||||
if (pathname === "/dashboard/products/uoms") return "Units of Measure"
|
|
||||||
if (pathname === "/dashboard/products/categories") return "Categories"
|
if (pathname === "/dashboard/products/categories") return "Categories"
|
||||||
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
|
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
|
||||||
if (pathname === "/dashboard/products/brands") return "Brands"
|
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
|
||||||
if (pathname === "/dashboard/products/item-types") return "Item Types"
|
if (pathname === "/dashboard/products/item-types") return "Item Types"
|
||||||
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
if (pathname === "/dashboard/products/settings") return "Product Configuration"
|
||||||
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
|
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function ThemeToggle({ className }: { className?: string }) {
|
|||||||
"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? isVibrant
|
? isVibrant
|
||||||
? "bg-linear-to-br from-indigo-500 to-violet-600 text-white shadow-sm"
|
? "bg-linear-to-br from-sidebar-primary to-primary text-primary-foreground shadow-sm"
|
||||||
: "bg-card text-primary shadow-sm ring-1 ring-foreground/10"
|
: "bg-card text-primary shadow-sm ring-1 ring-foreground/10"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-black text-white hover:bg-black/90 active:bg-black",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary",
|
||||||
outline:
|
outline:
|
||||||
"border-black bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
|
"border-primary bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/40",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_8%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_8%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ export function Combobox<Value>({
|
|||||||
<ComboboxPrimitive.Item
|
<ComboboxPrimitive.Item
|
||||||
key={String(item.value)}
|
key={String(item.value)}
|
||||||
value={item.value}
|
value={item.value}
|
||||||
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-violet-100 data-highlighted:text-violet-900 dark:data-highlighted:bg-violet-500/25 dark:data-highlighted:text-violet-200"
|
className="relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-8 pl-2.5 text-sm outline-hidden select-none data-highlighted:bg-secondary data-highlighted:text-secondary-foreground"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
<ComboboxPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
<CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
|
<CheckIcon className="size-4 text-primary" />
|
||||||
</ComboboxPrimitive.ItemIndicator>
|
</ComboboxPrimitive.ItemIndicator>
|
||||||
</ComboboxPrimitive.Item>
|
</ComboboxPrimitive.Item>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-inset:pl-7 data-popup-open:bg-secondary data-popup-open:text-secondary-foreground data-open:bg-secondary data-open:text-secondary-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -159,7 +159,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground focus:**:text-secondary-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground focus:**:text-secondary-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination"
|
||||||
|
import { PaginationMeta } from "@/types/common"
|
||||||
|
|
||||||
|
interface PaginationFooterProps {
|
||||||
|
pagination: PaginationMeta
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First, last, current ±1, and ellipses for the gaps — keeps the row from overflowing
|
||||||
|
* on tables with many pages while still surfacing the ends. */
|
||||||
|
function pageItems(current: number, total: number): (number | "ellipsis")[] {
|
||||||
|
const items: (number | "ellipsis")[] = [1]
|
||||||
|
if (current - 1 > 2) items.push("ellipsis")
|
||||||
|
for (let p = Math.max(2, current - 1); p <= Math.min(total - 1, current + 1); p++) items.push(p)
|
||||||
|
if (current + 1 < total - 1) items.push("ellipsis")
|
||||||
|
if (total > 1) items.push(total)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-paginated table footer: "X – Y of Z" plus numbered page links, built on the
|
||||||
|
* shadcn-style pagination primitives. Use for any list page driven by a `PaginationMeta`
|
||||||
|
* from the API (as opposed to `DataTable`'s own client-side pagination). */
|
||||||
|
export function PaginationFooter({ pagination, onPageChange }: PaginationFooterProps) {
|
||||||
|
const { page, pageSize, totalItems, totalPages } = pagination
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
|
||||||
|
const from = (page - 1) * pageSize + 1
|
||||||
|
const to = Math.min(page * pageSize, totalItems)
|
||||||
|
|
||||||
|
function go(e: React.MouseEvent, target: number) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (target >= 1 && target <= totalPages) onPageChange(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{from} – {to} of {totalItems}
|
||||||
|
</p>
|
||||||
|
<Pagination className="mx-0 w-auto justify-end">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
text=""
|
||||||
|
onClick={(e) => go(e, page - 1)}
|
||||||
|
aria-disabled={page === 1}
|
||||||
|
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{pageItems(page, totalPages).map((item, i) =>
|
||||||
|
item === "ellipsis" ? (
|
||||||
|
<PaginationItem key={`ellipsis-${i}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={item}>
|
||||||
|
<PaginationLink href="#" isActive={item === page} onClick={(e) => go(e, item)}>
|
||||||
|
{item}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
text=""
|
||||||
|
onClick={(e) => go(e, page + 1)}
|
||||||
|
aria-disabled={page === totalPages}
|
||||||
|
className={page === totalPages ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props<unknown>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props<unknown>) {
|
||||||
|
return (
|
||||||
|
<RadioPrimitive.Root
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-4 shrink-0 items-center justify-center rounded-full border border-input transition-colors outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 data-checked:border-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioPrimitive.Indicator
|
||||||
|
data-slot="radio-group-item-indicator"
|
||||||
|
className="flex items-center justify-center after:size-2 after:rounded-full after:bg-primary"
|
||||||
|
/>
|
||||||
|
</RadioPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -150,7 +150,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-violet-100 focus:text-violet-900 dark:focus:bg-violet-500/25 dark:focus:text-violet-200 not-data-[variant=destructive]:focus:**:text-violet-900 dark:not-data-[variant=destructive]:focus:**:text-violet-200 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-secondary focus:text-secondary-foreground not-data-[variant=destructive]:focus:**:text-secondary-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -163,7 +163,7 @@ function SelectItem({
|
|||||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CheckIcon className="pointer-events-none text-violet-600 dark:text-violet-300" />
|
<CheckIcon className="pointer-events-none text-primary" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,10 +76,11 @@ export function StatCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<Icon className="size-4 text-primary" />
|
<Icon className="size-3.5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +91,7 @@ export function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDelta && (
|
{hasDelta && (
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold",
|
"font-semibold",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
|
// One typed client method per UOM endpoint (docs/11-BACKEND-PHASE1.md §2.2, FR-MD-02).
|
||||||
// `list` also backs the GRN/PO line UOM picker.
|
// `list` also backs the GRN/PO line UOM picker.
|
||||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
import { PagedResponse } from "@/types/common"
|
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||||
import { CreateUomRequest, Uom } from "@/types/master-data"
|
import { CreateUomRequest, Uom } from "@/types/master-data"
|
||||||
|
|
||||||
export interface ListUomsParams {
|
export interface ListUomsParams {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
q?: string
|
q?: string
|
||||||
|
status?: EntityStatus
|
||||||
sort?: string
|
sort?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,4 +20,9 @@ export const uomsApi = {
|
|||||||
create(request: CreateUomRequest): Promise<Uom> {
|
create(request: CreateUomRequest): Promise<Uom> {
|
||||||
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
|
return apiRequest<Uom>("/uoms", { method: "POST", body: request })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Deactivate/reactivate. There is no DELETE — masters are never hard-deleted. */
|
||||||
|
updateStatus(uomId: number, status: EntityStatus): Promise<void> {
|
||||||
|
return apiRequest<void>(`/uoms/${uomId}/status`, { method: "PATCH", body: { status } })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
@@ -184,6 +184,9 @@ export type UpdateVendorRequest = CreateVendorRequest
|
|||||||
export interface Uom {
|
export interface Uom {
|
||||||
uomId: number
|
uomId: number
|
||||||
name: string
|
name: string
|
||||||
|
status: EntityStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUomRequest {
|
export interface CreateUomRequest {
|
||||||
|
|||||||
@@ -24,6 +24,25 @@ Principles:
|
|||||||
- **Keep components thin;** put data-fetching and derived state in hooks, request/response shaping in the API client.
|
- **Keep components thin;** put data-fetching and derived state in hooks, request/response shaping in the API client.
|
||||||
- **One typed client method per endpoint**, returning the DTO type from `types/`.
|
- **One typed client method per endpoint**, returning the DTO type from `types/`.
|
||||||
|
|
||||||
|
### 1.1 Design tokens (colour theme)
|
||||||
|
All colour comes from CSS custom properties in `app/globals.css` (`:root`, `.dark`, `.vibrant`), consumed as Tailwind utilities (`bg-primary`, `text-primary`, `bg-secondary`, …) via the `@theme inline` mapping — **never hardcode a hex/oklch value in a component.** To restyle the brand accent, change the token values in `globals.css`; every component that uses `bg-primary`/`text-primary`/`ring` picks it up automatically across all three themes (light/dark/vibrant).
|
||||||
|
|
||||||
|
> **2026-09-10 — brand accent switched to crimson.** `--primary` (and `--ring`, `--chart-1`, `--sidebar-primary`, `--sidebar-accent`) changed from blue/violet to a crimson-red (`oklch(0.586 0.222 17.585)` light/vibrant, `oklch(0.645 0.215 16.439)` dark) to match the reference brand look, purely a token-value change — no component structure, layout, or architecture changed. `components/ui/button.tsx`'s `default` variant now reads `bg-primary`/`text-primary-foreground` (was hardcoded `bg-black`/`text-white`), so this is the first theme change to actually flow through the shared Button rather than being fought by a one-off override. The Login screen (`app/login/page.tsx`) was updated to stop overriding the button with `bg-foreground` and instead take the shared `default` variant, and its "Forgot password?" / "Create an account" links now use `text-primary` instead of `text-foreground` to read as accent links. Same date: `components/ui/select.tsx` (`SelectItem`) and `components/ui/combobox.tsx` had a hardcoded `violet-*` highlight/check-mark color that ignored the theme entirely — fixed to token-driven colours, then aligned with the sidebar look (below).
|
||||||
|
|
||||||
|
> **2026-09-10 — dropdown/select/combobox highlight matches the sidebar's active-item colour.** `DropdownMenuItem`/`DropdownMenuSubTrigger`/`DropdownMenuCheckboxItem`/`DropdownMenuRadioItem` (`dropdown-menu.tsx`), `SelectItem` (`select.tsx`), and the combobox item (`combobox.tsx`) all switched their highlighted/focused state from `bg-accent`/`text-accent-foreground` (a separate teal token, unrelated to the brand colour) to `bg-secondary`/`text-secondary-foreground` — the same soft-crimson-tint token the sidebar's active nav item already uses for its background/text. Result: hovering or keyboard-focusing any menu, select, or combobox row now highlights in the same soft-crimson tone as the active sidebar item, in every theme (light/dark/vibrant), since both read the same token pair. `--accent` itself is untouched and still used elsewhere (e.g. `ghost` button hover) as a neutral highlight.
|
||||||
|
|
||||||
|
> **2026-09-10 — theme-toggle "Vibrant" segment matches the sidebar/brand colour.** `components/theme-toggle.tsx`'s active "Vibrant" (monitor icon) segment was hardcoded to `bg-linear-to-br from-indigo-500 to-violet-600` — the only spot in the toggle not driven by a token, so it kept showing indigo/violet regardless of the brand colour. Changed to `from-sidebar-primary to-primary` with `text-primary-foreground`, so the active pill now renders a crimson gradient matching the sidebar's active-item colour, and will track any future `--primary`/`--sidebar-primary` change automatically.
|
||||||
|
|
||||||
|
> **2026-09-10 — Hexa ERP logo re-coloured, background removed.** `public/images/hexa-erp-logo.png` (used by both `AppSidebar.tsx`'s header and `app/login/page.tsx`) was a raster image with a baked-in near-white background and blue/navy/gray brand colours — not something a CSS/token change can touch, since it's pixels, not markup. Reprocessed with a one-off Node/`sharp` script (not committed — this was an asset edit, not a code change) that made the near-white background fully transparent (with soft alpha at the antialiased edges, not a hard cutout) and remapped the blue/navy hues to the same crimson used by `--primary`/`--sidebar-primary`, while leaving the "ERP" wordmark and its hexagon a neutral gray. The original is kept alongside as `hexa-erp-logo.png.bak` in case the brand mark itself needs to be redone by a designer later. If the logo is ever replaced, prefer sourcing a transparent-background asset directly rather than re-running the extraction script, which is a best-effort colour/alpha heuristic, not a general tool.
|
||||||
|
|
||||||
|
> **2026-09-10 — dashboard stat cards sized down.** `components/ui/stat-card.tsx` (the top row of the dashboard: Low Stock Alerts, Stock On-Hand, etc.) had generous spacing (`p-5`, `gap-3`, `text-base` label, `size-9` icon, `text-xl` value) that read as oversized once the cards' third row (`delta`/`trend`, both optional props the dashboard never passes) was empty — most of a card's height was whitespace. Tightened to `p-4`, `gap-2`, `text-sm` label, `size-7` icon, `text-lg` value, `text-xs` delta row — same content, same props/behaviour, just denser. `StatCard` has exactly one call site (`app/dashboard/page.tsx`), so this didn't need reconciling against any other screen. Same date: the grid wrapping those cards went from `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` (wrapped to two rows) to `grid-cols-2 sm:grid-cols-4 lg:grid-cols-7` (all 7 in one row), then the **"Pending Approval POs" card was removed** from the dashboard entirely (`app/dashboard/page.tsx`) and the grid settled at `grid-cols-2 sm:grid-cols-3 lg:grid-cols-6` for the remaining 6 — a deliberate content + layout call, not a bug fix. `stats.pendingApprovalPurchaseOrders` and the `DashboardStats` type are untouched (backend/API contract, out of scope for a UI-only change) — only the card and its now-unused `Clock` icon import were removed from the page.
|
||||||
|
|
||||||
|
> **2026-09-10 — the two dashboard chart panels sit side by side.** "Stock Valuation by Warehouse" (bar chart) and "Stock Movement Trend" (line chart) on `app/dashboard/page.tsx` were two full-width `rounded-2xl` panels stacked vertically. Wrapped both in a `grid grid-cols-1 lg:grid-cols-2 gap-4` container so they sit in one row at `lg` and above, and stack on narrower screens — each panel's own markup/content is unchanged, only the wrapping grid was added.
|
||||||
|
|
||||||
|
> **2026-09-10 — "Stock Valuation by Warehouse" bar colour matches the brand crimson.** The bar dataset's `backgroundColor` was a hardcoded indigo `#6366f1`, left over from before the crimson theme change and not updated with it. Changed to `#E11D48` (the same crimson as `--primary`). `components/ui/bar-chart.tsx`/`line-chart.tsx` are thin Chart.js wrappers that take a plain CSS colour string per dataset — Canvas rendering can't reliably resolve the app's `oklch()` custom properties, so (matching the Line chart's existing "In"/"Out" green/red, also literal hex) the call site passes a literal hex kept in sync with the token by hand rather than a `var(--primary)` reference. The Line chart's green ("In") / red ("Out") were left as-is: those are semantic (stock direction), not brand colour, and forcing them to crimson would remove the in/out distinction.
|
||||||
|
|
||||||
|
> **2026-09-10 — login-page illustration's UI-mockup panels recoloured to crimson.** `public/images/loginerps.png` (light mode) and `public/images/whithoutbackground.png` (dark mode, `app/login/page.tsx`'s two side-by-side hero images) are photorealistic scenes — two people at a table plus three floating UI-mockup panels ("Inventory Management", "Order Processing", "Integrated Analytics") in blue/teal. Unlike the flat-vector logo, a global hue-shift here would also discolour the people (navy suits/hair share the same blue hue range as the panel chrome — verified by direct HSL sampling, not assumption), so the recolour script is restricted to three hand-verified pixel rectangles covering just the panels, and only remaps hue 180-230° pixels with real saturation/lightness (`spread ≥ 40`, `s ≥ 0.25`, `0.2 ≤ l ≤ 0.85`) to crimson, blending by saturation so text/borders/antialiasing aren't speckled. Greens (the "Delivered" status pill, success ring, checkmarks) are left alone — same semantic-colour reasoning as the dashboard charts, above. `whithoutbackground.png` additionally got the same white-to-transparent treatment as the logo (it turned out to be a flat white background despite the filename, not real alpha) in the same pass. Both originals are kept as `loginerps.png.bak` / `whithoutbackground.png.bak`. This is a best-effort raster edit (not a general tool, and not committed as a script) — if a designer ever redoes this illustration, recolour it at the source rather than re-running the heuristic.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. User flows
|
## 2. User flows
|
||||||
|
|||||||