Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka c2b92f3086 new updates 2026-09-17 14:22:45 +05:30
85 changed files with 1025 additions and 17341 deletions
-106
View File
@@ -1,106 +0,0 @@
# test
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
backend-build-and-deploy:
name: Build & Deploy ERP Core Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore backend
working-directory: .gitea/workflows
run: dotnet restore ../../Backend/ERPCore.sln
- name: Publish backend
working-directory: .gitea/workflows
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish ../../Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop backend service
run: systemctl stop erpcore.service
- name: Copy backend to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix backend ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart backend service
run: systemctl restart erpcore.service
- name: Verify backend
run: |
systemctl is-active erpcore.service && echo "Backend service running" || echo "Backend service failed"
frontend-build-and-deploy:
name: Build & Deploy ERP Core Frontend
runs-on: linux
needs: backend-build-and-deploy
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch Production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build frontend Docker image
working-directory: .gitea/workflows
run: docker build -t erpcore-frontend:latest ../../Frontend/erp-system
- name: Stop & remove existing frontend container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start frontend container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old frontend images
run: docker image prune -f
- name: Verify frontend
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "Frontend container running" || echo "Frontend container failed"
+2 -14
View File
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
@@ -19,9 +18,8 @@ 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( public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) => Ok(await _uoms.ListAsync(query, ct));
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -31,14 +29,4 @@ 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();
}
} }
-6
View File
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
@@ -12,8 +10,4 @@ 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 -7
View File
@@ -1,17 +1,11 @@
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, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); public sealed record UomDto(int UomId, string Name);
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,5 +1,4 @@
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;
@@ -14,13 +13,5 @@ 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);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,61 +0,0 @@
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class changeMismatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -6345,31 +6345,16 @@ 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);
}); });
@@ -6,7 +6,7 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "http://localhost:6701", "applicationUrl": "http://localhost:5224",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -16,7 +16,7 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "https://localhost:7112;http://localhost:6701", "applicationUrl": "https://localhost:7112;http://localhost:5224",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms; using ERPCore.Dtos.Uoms;
@@ -7,7 +6,6 @@ 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, EntityStatus? status, CancellationToken ct = default); Task<PagedResponse<UomDto>> ListAsync(PageQuery query, 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);
} }
+4 -16
View File
@@ -1,5 +1,4 @@
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;
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
_uow = uow; _uow = uow;
} }
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{ {
var q = _uoms.Query().AsNoTracking(); var q = _uoms.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -29,12 +28,11 @@ 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, u.Status, u.CreatedAt, u.UpdatedAt)) .Select(u => new UomDto(u.UomId, u.Name))
.ToListAsync(ct); .ToListAsync(ct);
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total); return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
@@ -46,20 +44,10 @@ 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, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }; var uom = new Uom { Name = name };
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, uom.Status, uom.CreatedAt, uom.UpdatedAt); return new UomDto(uom.UomId, uom.Name);
}
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);
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive" "DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5011" "BaseUrl": "http://localhost:5011"
@@ -1,4 +1,7 @@
{ {
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": { "Jwt": {
"SigningKey": "" "SigningKey": ""
}, },
+2 -3
View File
@@ -6,9 +6,8 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive" "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
}, },
"Urls": "http://0.0.0.0:6701",
"Auth": { "Auth": {
"Issuer": "AuthHex", "Issuer": "AuthHex",
"Audience": "AuthHexClient", "Audience": "AuthHexClient",
@@ -17,7 +16,7 @@
"RequiredRoleCode": "" "RequiredRoleCode": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:6703" "BaseUrl": "http://localhost:5602"
}, },
"FileStorage": { "FileStorage": {
"RootPath": "App_Data/hr-documents", "RootPath": "App_Data/hr-documents",
-18
View File
@@ -1,18 +0,0 @@
node_modules
.next
out
build
coverage
.vercel
.env
.env.*
!.env.local.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
Dockerfile
.dockerignore
+1 -1
View File
@@ -4,4 +4,4 @@
# #
# Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the # Use the backend's HTTP port: its HTTPS port serves a self-signed dev cert that the
# proxy will refuse. # proxy will refuse.
BACKEND_ORIGIN=http://localhost:6701 BACKEND_ORIGIN=http://localhost:5224
-32
View File
@@ -1,32 +0,0 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=6702
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
EXPOSE 6702
CMD ["npm", "run", "start"]
@@ -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"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{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">{formatReportDate(a.createdAt)}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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">{formatReportDate(p.issueDate)}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`} {bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{branchesById.get(b.branchId)?.name ?? `#${b.branchId}`}</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"> <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">{formatReportDate(b.receivedDate)}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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">{c.receivedFromType}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{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">{formatReportDate(c.receivedDate)}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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,12 +15,11 @@ 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 = 25 const PAGE_SIZE = 10
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",
@@ -138,7 +137,16 @@ export default function AttendanceBatchesPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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,9 +15,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
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 { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Field, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { FilterBar } from "@/components/ui/filter-bar"
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 { StatCard } from "@/components/ui/stat-card" import { StatCard } from "@/components/ui/stat-card"
@@ -18,7 +18,6 @@ import { Badge } from "@/components/ui/badge"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
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,7 +15,6 @@ 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,7 +16,6 @@ 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,7 +16,6 @@ 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,7 +16,6 @@ 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,7 +16,6 @@ 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">{row.narration ?? "—"}</TableCell> <TableCell className="px-3 py-2.5 text-muted-foreground">{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>
+59 -57
View File
@@ -6,6 +6,7 @@ import {
AlertTriangle, AlertTriangle,
BadgeDollarSign, BadgeDollarSign,
Boxes, Boxes,
Clock,
ClipboardList, ClipboardList,
ListChecks, ListChecks,
PackageCheck, PackageCheck,
@@ -127,7 +128,7 @@ export default function DashboardPage() {
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-6"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-4">
{loaded ? ( {loaded ? (
<> <>
<Link href="/dashboard/stock/reorder-alerts"> <Link href="/dashboard/stock/reorder-alerts">
@@ -136,6 +137,9 @@ 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>
@@ -150,68 +154,66 @@ export default function DashboardPage() {
</Link> </Link>
</> </>
) : ( ) : (
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />) !error && Array.from({ length: 7 }).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"> <BadgeDollarSign className="size-4 shrink-0 text-primary" />
<BadgeDollarSign className="size-4 shrink-0 text-primary" /> Stock Valuation by Warehouse
Stock Valuation by Warehouse </h2>
</h2> <Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}> View details
View details </Link>
</Link>
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-52 sm:h-64">
<BarChart
labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[
{
label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#E11D48",
},
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div> </div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5"> {loaded ? (
<div className="mb-4 flex items-center gap-2"> (stats.stockValuationByWarehouse ?? []).length > 0 ? (
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div>
{loaded ? (
<div className="h-52 sm:h-64"> <div className="h-52 sm:h-64">
<LineChart <BarChart
labels={movementTrend.labels} labels={stats.stockValuationByWarehouse.map(
(w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`
)}
datasets={[ datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" }, {
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" }, label: "Stock Value (LKR)",
data: stats.stockValuationByWarehouse.map((w) => w.total),
backgroundColor: "#6366f1",
},
]} ]}
/> />
</div> </div>
) : ( ) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" /> <p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)} )
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {TREND_DAYS} days)
</h2>
</div> </div>
{loaded ? (
<div className="h-52 sm:h-64">
<LineChart
labels={movementTrend.labels}
datasets={[
{ label: "In", data: movementTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: movementTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
!error && <Skeleton className="h-52 rounded-lg sm:h-64" />
)}
</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">
@@ -241,9 +243,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> <TableCell className="text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId} {warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
@@ -258,11 +260,11 @@ export default function DashboardPage() {
{entry.direction} {entry.direction}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{entry.qtyBase}</TableCell> <TableCell className="text-muted-foreground">{entry.qtyBase}</TableCell>
<TableCell> <TableCell className="text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId} {entry.sourceDocType} #{entry.sourceDocId}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right text-muted-foreground">
{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">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3 text-muted-foreground">{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">{new Date(g.postedAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3 text-muted-foreground">{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,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react" import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -11,7 +11,6 @@ import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
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"
@@ -24,7 +23,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = PurchaseInvoiceStatus | "All" type StatusFilter = PurchaseInvoiceStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 10
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" },
@@ -217,10 +216,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">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{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">{row.totals.vatTotal.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 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>
{isAdmin && branchingEnabled && ( {isAdmin && branchingEnabled && (
<TableCell className="px-4 py-3.5"> <TableCell className="px-4 py-3.5">
@@ -295,9 +294,22 @@ export default function PurchaseInvoicesPage() {
</div> </div>
</div> </div>
{pagination && ( {pagination && pagination.totalPages > 1 && (
<div className="border-t px-4 py-4"> <div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<PaginationFooter pagination={pagination} onPageChange={setPage} /> <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>
)} )}
</> </>
@@ -19,7 +19,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/component
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
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 { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
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"
@@ -29,7 +28,7 @@ import { PoStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = PurchaseOrderStatus | "All" type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 10
export default function PurchaseOrdersListPage() { export default function PurchaseOrdersListPage() {
const stats = useDashboardStats() const stats = useDashboardStats()
@@ -298,7 +297,31 @@ export default function PurchaseOrdersListPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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">{r.glJournalNo ?? "—"}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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,9 +14,8 @@ import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
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"
@@ -24,7 +23,7 @@ import { RequisitionStatusBadge } from "@/components/procurement/status-badges"
type StatusFilter = RequisitionStatus | "All" type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 10
export default function RequisitionsListPage() { export default function RequisitionsListPage() {
const stats = useDashboardStats() const stats = useDashboardStats()
@@ -153,7 +152,29 @@ export default function RequisitionsListPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"} {line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -19,7 +19,6 @@ 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"
@@ -426,7 +425,22 @@ export default function ProductionRunsPage() {
})} })}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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,7 +22,6 @@ 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"
@@ -287,7 +286,26 @@ export default function ProductionTemplatesPage() {
)} )}
</div> </div>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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,193 +1,223 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import Link from "next/link"
import { Plus, Ruler, Tag } from "lucide-react" import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react"
import { brandsApi } from "@/lib/api/brands" import { brandsApi } from "@/lib/api/brands"
import { uomsApi } from "@/lib/api/uoms" import { errorMessage } from "@/lib/error-map"
import { errorMessage, fieldErrors } from "@/lib/error-map" import { validateBrandName } from "@/lib/validations/master-data"
import { validateBrandName, validateUomName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Brand, Uom } from "@/types/master-data" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Brand } from "@/types/master-data"
import { Checkbox } from "@/components/ui/checkbox" import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge"
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 } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } 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 Tab = "brand" | "uom" type SortOrder = "asc" | "desc"
type SortKey = "brandId" | "name" | "status" | "createdAt"
type StatusFilter = EntityStatus | "All"
// Large enough to show "all brands" in one grid — the chip layout replaces the const PAGE_SIZE = 5
// 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 [togglingId, setTogglingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [editOpen, setEditOpen] = useState(false) useEffect(() => {
const [editing, setEditing] = useState<Brand | null>(null) const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
const [editName, setEditName] = useState("") return () => clearTimeout(timeout)
const [editErrors, setEditErrors] = useState<Record<string, string>>({}) }, [searchInput])
const [editSubmitting, setEditSubmitting] = useState(false)
useEffect(() => {
setPage(1)
}, [search, status])
function load() { function load() {
setError(null) setError(null)
brandsApi brandsApi
.list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .list({ q: search || undefined, status: status === "All" ? undefined : status, sort: "name", page, pageSize: PAGE_SIZE })
.then((res) => setBrands(res.items)) .then((res) => {
setBrands(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
} }
useEffect(load, []) useEffect(load, [search, status, page])
async function handleAdd() { const hasFilters = search.trim().length > 0 || status !== "All"
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 {
const result = await brandsApi.create({ name }) let result
toast.success("Brand created", result.data.name) if (editing) {
// 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("Could not create brand", errorMessage(err)) toast.error(editing ? "Could not update brand" : "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 handleToggleActive(brand: Brand) { async function handleToggleStatus(brand: Brand) {
const next = brand.status === "Active" ? "Inactive" : "Active" const next = brand.status === "Active" ? "Inactive" : "Active"
setTogglingId(brand.brandId) setDeletingId(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 {
setTogglingId(null) setDeletingId(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"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input <div>
value={name} <h1 className="text-2xl font-bold text-foreground">Brands</h1>
onChange={(e) => setName(e.target.value)} <p className="text-base text-muted-foreground">Manage product brands.</p>
placeholder="New brand name, e.g. Bosch" </div>
className="h-12 flex-1 text-base"
aria-label="New brand name" <Dialog open={open} onOpenChange={setOpen}>
aria-invalid={!!errors.name} <DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Brand</Button>} />
onKeyDown={(e) => e.key === "Enter" && handleAdd()} <DialogContent className="sm:max-w-sm">
/> <DialogHeader className="items-center text-center">
<Button size="lg" onClick={handleAdd} disabled={submitting}> <DialogTitle>{editing ? "Edit brand" : "New brand"}</DialogTitle>
<Plus className="size-5" /> <DialogDescription>Give the brand a name.</DialogDescription>
Add Brand </DialogHeader>
</Button> <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="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
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search brands…"
className="h-14 w-full pl-11 text-base"
aria-label="Search brands"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<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> </div>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
{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="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" /> <Skeleton key={i} className="h-14 w-full" />
))} ))}
</div> </div>
)} )}
@@ -195,171 +225,162 @@ function BrandPanel() {
{!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">No brands yet.</p> <p className="text-base text-muted-foreground">
{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"> <>
{brands.map((b) => ( <Table className="text-base">
<label <TableHeader>
key={b.brandId} <TableRow>
onDoubleClick={() => openEdit(b)} <TableHead className="h-12 px-3 text-sm">
title="Tick to activate/deactivate — double-click to rename" <SortableHeader label="ID" active={sortKey === "brandId"} order={sortOrder} onClick={() => toggleSort("brandId")} />
className="flex items-center gap-3 rounded-xl border border-input px-4 py-3 text-base select-none hover:bg-muted/50" </TableHead>
> <TableHead className="h-12 px-3 text-sm">
<Checkbox <SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
checked={b.status === "Active"} </TableHead>
onCheckedChange={() => handleToggleActive(b)} <TableHead className="h-12 px-3 text-sm">
disabled={togglingId === b.brandId} <SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
/> </TableHead>
<span className="font-medium">{b.name}</span> <TableHead className="h-12 px-3 text-sm">
</label> <SortableHeader label="Created At" active={sortKey === "createdAt"} order={sortOrder} onClick={() => toggleSort("createdAt")} />
))} </TableHead>
</div> <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}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
<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>
<Dialog open={editOpen} onOpenChange={setEditOpen}> {/* Deactivate, not delete: the API has no DELETE for any master
<DialogContent className="sm:max-w-sm"> (FR-MD-08) — records referenced by transactions must survive. */}
<DialogHeader className="items-center text-center"> <AlertDialog>
<DialogTitle>Edit brand</DialogTitle> <AlertDialogTrigger
<DialogDescription>Give the brand a name.</DialogDescription> render={
</DialogHeader> <Button
<FieldGroup> variant="ghost"
<Field data-invalid={!!editErrors.name}> size="icon-sm"
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel> className={b.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
<Input aria-label={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}`}
id="brand-edit-name" disabled={deletingId === b.brandId}
value={editName} />
onChange={(e) => setEditName(e.target.value)} }
aria-invalid={!!editErrors.name} >
/> {b.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} /> </AlertDialogTrigger>
</Field> <AlertDialogContent
</FieldGroup> variant={b.status === "Active" ? "destructive" : "success"}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center"> title={`${b.status === "Active" ? "Deactivate" : "Activate"} ${b.name}?`}
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={editSubmitting}> description={
Cancel b.status === "Active"
</Button> ? "The brand stays on existing items but cannot be assigned to new ones."
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleEditSubmit} disabled={editSubmitting}> : "The brand becomes selectable again."
{editSubmitting ? "Saving…" : "Save"} }
</Button> confirmLabel={b.status === "Active" ? "Deactivate" : "Activate"}
</div> onConfirm={() => handleToggleStatus(b)}
</DialogContent> />
</Dialog> </AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<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>
) )
} }
function UomPanel() { function SortableHeader({
const [uoms, setUoms] = useState<Uom[] | null>(null) label,
const [error, setError] = useState<string | null>(null) active,
order,
const [name, setName] = useState("") onClick,
const [errors, setErrors] = useState<Record<string, string>>({}) }: {
const [submitting, setSubmitting] = useState(false) label: string
const [togglingId, setTogglingId] = useState<number | null>(null) active: boolean
order: SortOrder
function load() { onClick: () => void
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 (
<div className="flex flex-col gap-6"> <button
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> type="button"
<Input className="flex items-center gap-1 hover:text-foreground"
value={name} onClick={onClick}
onChange={(e) => setName(e.target.value)} aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
placeholder="New UOM name, e.g. Box-12" >
className="h-12 flex-1 text-base" {label}
aria-label="New UOM name" {active ? (
aria-invalid={!!errors.name} order === "asc" ? (
onKeyDown={(e) => e.key === "Enter" && handleAdd()} <ArrowUp className="size-3.5" />
/> ) : (
<Button size="lg" onClick={handleAdd} disabled={submitting}> <ArrowDown className="size-3.5" />
<Plus className="size-5" /> )
Add UOM ) : (
</Button> <ArrowUpDown className="size-3.5 text-muted-foreground/50" />
</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">{s.subCategoryId}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">#{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">{new Date(s.createdAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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,15 +2,14 @@
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, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, 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, ProductConfig } from "@/types/master-data" import { Category } 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"
@@ -18,7 +17,6 @@ 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"
@@ -28,7 +26,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 = 25 const PAGE_SIZE = 5
export default function CategoriesPage() { export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[] | null>(null) const [categories, setCategories] = useState<Category[] | null>(null)
@@ -50,36 +48,6 @@ 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)
@@ -279,14 +247,13 @@ 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">{c.categoryId}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">#{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
@@ -299,23 +266,17 @@ export default function CategoriesPage() {
{c.status} {c.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{new Date(c.createdAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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. */} <Link
{config?.subcategoriesEnabled && ( href={`/dashboard/products/categories/${c.categoryId}`}
<Link className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
href={`/dashboard/products/categories/${c.categoryId}`} aria-label={`Manage subcategories of ${c.name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} >
aria-label={`Manage subcategories of ${c.name}`} <Network className="size-4" />
> </Link>
<Network className="size-4" />
</Link>
)}
<Button <Button
variant="ghost" variant="ghost"
@@ -360,7 +321,39 @@ export default function CategoriesPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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">{t.itemTypeId}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">#{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">{new Date(t.createdAt).toLocaleDateString()}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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,7 +42,6 @@ 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"
@@ -721,122 +720,136 @@ export default function NewItemPage() {
</div> </div>
</div> </div>
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && ( {config?.sellingPricesEnabled && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="flex flex-col gap-4 rounded-xl border p-5">
{config?.sellingPricesEnabled && ( <div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div className="flex flex-col gap-4 rounded-xl border p-5"> <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"> Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column.
Edit the min, max, and flat price per generated variant. The bulk default only fills the flat column. </p>
</p> </div>
</div>
<RadioGroup <div className="inline-flex w-fit rounded-lg border p-1">
value={priceMode} <button
onValueChange={(v) => setPriceMode(v as typeof priceMode)} type="button"
className="flex flex-col gap-3" onClick={() => setPriceMode("stock")}
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",
)}
> >
<label className="flex items-center gap-2.5"> Use stock value
<RadioGroupItem value="stock" /> </button>
<span className="text-base font-medium">Use stock value</span> <button
</label> type="button"
<label className="flex items-center gap-2.5"> onClick={() => setPriceMode("fixed")}
<RadioGroupItem value="fixed" /> className={cn(
<span className="text-base font-medium">Fixed price</span> "rounded-md px-4 py-2 text-base font-medium transition-colors",
</label> priceMode === "fixed" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
</RadioGroup> )}
>
Fixed price
</button>
</div>
</div>
{priceMode === "fixed" && ( {priceMode === "fixed" && (
<div className="flex flex-wrap items-end gap-3"> <div className="flex max-w-xl 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
type="number" type="number"
min="0" min="0"
step="0.01" step="0.01"
inputMode="decimal" inputMode="decimal"
value={defaultFlatPrice} value={defaultFlatPrice}
onChange={(e) => setDefaultFlatPrice(e.target.value)} onChange={(e) => setDefaultFlatPrice(e.target.value)}
placeholder="0.00" placeholder="0.00"
className="h-11 text-base" className="h-11 text-base"
/> />
</div> </div>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
const raw = defaultFlatPrice.trim() const raw = defaultFlatPrice.trim()
if (!raw) return if (!raw) return
setPricesByKey((prev) => { setPricesByKey((prev) => {
const next = { ...prev } const next = { ...prev }
for (const variant of variants) { for (const variant of variants) {
if (next[variant.key] == null || next[variant.key].trim() === "") { if (next[variant.key] == null || next[variant.key].trim() === "") {
next[variant.key] = raw next[variant.key] = raw
} }
} }
return next return next
}) })
}} }}
> >
Fill blank flat prices Fill blank flat prices
</Button> </Button>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This copies the same value into blank flat-price cells only. This copies the same value into blank flat-price cells only.
</p> </p>
</div>
)}
</div> </div>
)} )}
</div>
)}
{config?.warrantyPeriodsEnabled && ( {config?.warrantyPeriodsEnabled && (
<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> <div>
<h2 className="text-lg font-semibold text-foreground">Warranty</h2> <h2 className="text-lg font-semibold text-foreground">Warranty</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Mark whether every generated variant is sold under warranty. Mark whether every generated variant is sold under warranty.
</p> </p>
</div> </div>
<RadioGroup <div className="inline-flex w-fit rounded-lg border p-1">
value={warranty} <button
onValueChange={(v) => { type="button"
setWarranty(v as typeof warranty) onClick={() => {
if (v === "NonWarranty") setWarrantyPeriodMonths(null) setWarranty("NonWarranty")
}} setWarrantyPeriodMonths(null)
className="flex flex-col gap-3" }}
> className={cn(
<label className="flex items-center gap-2.5"> "rounded-md px-4 py-2 text-base font-medium transition-colors",
<RadioGroupItem value="NonWarranty" /> warranty === "NonWarranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
<span className="text-base font-medium">Non-Warranty</span>
</label>
<label className="flex items-center gap-2.5">
<RadioGroupItem value="Warranty" />
<span className="text-base font-medium">Warranty</span>
</label>
</RadioGroup>
{warranty === "Warranty" && (
<div className="mt-2 flex flex-col gap-1.5">
<Label>Warranty period +</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div>
)} )}
>
Non-Warranty
</button>
<button
type="button"
onClick={() => setWarranty("Warranty")}
className={cn(
"rounded-md px-4 py-2 text-base font-medium transition-colors",
warranty === "Warranty" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
>
Warranty
</button>
</div>
{warranty === "Warranty" && (
<div className="flex flex-col gap-1.5">
<Label>Warranty period</Label>
<Select<WarrantyPeriodMonths>
value={warrantyPeriodMonths}
onValueChange={(v) => v && setWarrantyPeriodMonths(v)}
>
<SelectTrigger className="h-11! w-48 text-base" aria-invalid={!!errors.warrantyPeriodMonths}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
{WARRANTY_PERIOD_MONTHS_OPTIONS.map((months) => (
<SelectItem key={months} value={months} className="text-base">
{months} months
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.warrantyPeriodMonths ? { message: errors.warrantyPeriodMonths } : undefined]} />
</div> </div>
)} )}
</div> </div>
@@ -875,7 +888,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 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2", "flex 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"
)} )}
> >
@@ -1034,7 +1047,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"> <TableCell className="px-3 py-2.5 text-muted-foreground">
{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 { ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react" import { ChevronLeft, ChevronRight, 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,9 +14,8 @@ 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 { 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 { 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"
@@ -24,7 +23,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 = 25 const PAGE_SIZE = 10
export default function ItemsPage() { export default function ItemsPage() {
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -104,6 +103,7 @@ 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,6 +208,7 @@ 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>
@@ -223,16 +224,17 @@ 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"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{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"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{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"
@@ -258,7 +260,31 @@ export default function ItemsPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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,16 +1,129 @@
"use client" "use client"
import { useEffect } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { Plus, Ruler } from "lucide-react"
// UOM now lives as a tab on the merged Brand/UOM page — this route stays only import { uomsApi } from "@/lib/api/uoms"
// to keep old links/bookmarks working. import { errorMessage, fieldErrors } from "@/lib/error-map"
export default function UomsRedirectPage() { import { validateUomName } from "@/lib/validations/master-data"
const router = useRouter() import { Uom } from "@/types/master-data"
useEffect(() => { import { Button } from "@/components/ui/button"
router.replace("/dashboard/products/brands?tab=uom") import { Input } from "@/components/ui/input"
}, [router]) import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
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"
return null export default function UomsPage() {
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>
)
} }
@@ -1123,7 +1123,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"> <TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">
{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">
@@ -14,7 +14,6 @@ import { useDashboardStats } from "@/hooks/use-dashboard-stats"
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 { KpiRow } from "@/components/ui/kpi-row" import { KpiRow } from "@/components/ui/kpi-row"
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 {
@@ -30,7 +29,7 @@ import { GrnStatusBadge } from "@/components/receiving/status-badges"
type StatusFilter = GrnStatus | "All" type StatusFilter = GrnStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 5
export default function GrnListPage() { export default function GrnListPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
@@ -240,7 +239,39 @@ export default function GrnListPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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"> <TableCell className="px-4 py-2 min-w-40 text-sm text-muted-foreground">
{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">{t.description ?? "—"}</TableCell> <TableCell className="px-4 py-3.5 text-muted-foreground">{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">{idx + 1}</TableCell> <TableCell className="px-4 py-2 text-xs text-muted-foreground">{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"> <TableCell className="px-4 py-2 min-w-44 text-sm text-muted-foreground">
{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">editing</TableCell> <TableCell className="px-4 py-2 text-right text-xs text-muted-foreground">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">{idx + 1}</TableCell> <TableCell className="px-4 py-2 text-xs text-muted-foreground">{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}
@@ -624,7 +624,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">{idx + 1}</TableCell> <TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{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">
@@ -635,7 +635,7 @@ function NewSalesInvoiceContent() {
))} ))}
</ul> </ul>
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top text-sm">—</TableCell> <TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">—</TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Input <Input
type="number" type="number"
@@ -648,11 +648,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"> <TableCell className="px-3 py-3 align-top text-right text-sm text-muted-foreground">
{bundleFreeQty || "—"} {bundleFreeQty || "—"}
</TableCell> </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 text-sm text-muted-foreground">—</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>
@@ -678,7 +678,7 @@ function NewSalesInvoiceContent() {
return ( return (
<TableRow key={line.key}> <TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell> <TableCell className="px-3 py-3 align-top text-sm text-muted-foreground">{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}
@@ -28,7 +28,7 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All" type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 10
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" },
@@ -246,11 +246,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">{new Date(row.createdAt).toLocaleDateString()}</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.invoiceDate).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 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">-{row.totals.discountTotal.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 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">
@@ -320,9 +320,22 @@ export default function SalesInvoicesPage() {
</div> </div>
</div> </div>
{pagination && ( {pagination && pagination.totalPages > 1 && (
<div className="border-t px-4 py-4"> <div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<PaginationFooter pagination={pagination} onPageChange={setPage} /> <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>
)} )}
</> </>
@@ -111,7 +111,7 @@ export default function SalesReturnsListPage() {
{r.status} {r.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{r.glJournalNo ?? "—"}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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,16 +14,6 @@ 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,17 +13,6 @@ 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">{a.glJournalNo ?? "—"}</TableCell> <TableCell className="px-3 py-3.5 text-muted-foreground">{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>
))} ))}
@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { ScrollText } from "lucide-react" import { ChevronLeft, ChevronRight, 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 = 25 const PAGE_SIZE = 10
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"> <TableCell className="px-3 py-3.5 text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId} {entry.sourceDocType} #{entry.sourceDocId}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -206,7 +206,33 @@ export default function StockLedgerPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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>
@@ -191,7 +191,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>
+35 -4
View File
@@ -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 { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react" import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors" import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters" import { branchesHrmApi } from "@/lib/api/hrm-masters"
@@ -28,7 +28,6 @@ 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"
@@ -36,7 +35,7 @@ import { toast } from "@/components/ui/toast"
type StatusFilter = EntityStatus | "All" type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25 const PAGE_SIZE = 5
export default function VendorsPage() { export default function VendorsPage() {
const { isAdmin, activeBranchId, branchingEnabled } = useAuth() const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
@@ -378,7 +377,39 @@ export default function VendorsPage() {
</TableBody> </TableBody>
</Table> </Table>
{pagination && <PaginationFooter pagination={pagination} onPageChange={setPage} />} {pagination && pagination.totalPages > 1 && (
<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>
+23 -24
View File
@@ -59,11 +59,10 @@
--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.969 0.015 12.422); --secondary: oklch(0.92 0.055 264.901);
--secondary-foreground: oklch(0.514 0.198 16.935); --secondary-foreground: oklch(0.463 0.258 262.881);
--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);
@@ -71,12 +70,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.586 0.222 17.585); --ring: oklch(0.463 0.258 262.881);
--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.586 0.222 17.585); --chart-1: oklch(0.463 0.258 262.881);
--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);
@@ -84,12 +83,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.586 0.222 17.585); --sidebar-primary: oklch(0.463 0.258 262.881);
--sidebar-primary-foreground: oklch(0.98 0 0); --sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.969 0.015 12.422); --sidebar-accent: oklch(0.92 0.055 264.901);
--sidebar-accent-foreground: oklch(0.514 0.198 16.935); --sidebar-accent-foreground: oklch(0.463 0.258 262.881);
--sidebar-border: oklch(0.925 0.01 0); --sidebar-border: oklch(0.925 0.01 0);
--sidebar-ring: oklch(0.586 0.222 17.585); --sidebar-ring: oklch(0.463 0.258 262.881);
} }
.dark { .dark {
@@ -99,9 +98,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.645 0.215 16.439); --primary: oklch(0.68 0.186 265.215);
--primary-foreground: oklch(0.125 0 0); --primary-foreground: oklch(0.125 0 0);
--secondary: oklch(0.28 0.06 16.439); --secondary: oklch(0.28 0.04 264.901);
--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);
@@ -110,24 +109,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.645 0.215 16.439); --ring: oklch(0.68 0.186 265.215);
--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.645 0.215 16.439); --chart-1: oklch(0.68 0.186 265.215);
--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.645 0.215 16.439); --sidebar-primary: oklch(0.68 0.186 265.215);
--sidebar-primary-foreground: oklch(0.95 0 0); --sidebar-primary-foreground: oklch(0.95 0 0);
--sidebar-accent: oklch(0.28 0.06 16.439); --sidebar-accent: oklch(0.28 0.04 264.901);
--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.645 0.215 16.439); --sidebar-ring: oklch(0.68 0.186 265.215);
} }
/* Vibrant — the "System" toggle option. Light content area (background, /* Vibrant — the "System" toggle option. Light content area (background,
@@ -142,10 +141,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.586 0.222 17.585); --primary: oklch(0.55 0.2 275);
--primary-foreground: oklch(0.98 0 0); --primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.955 0.02 17.585); --secondary: oklch(0.93 0.02 275);
--secondary-foreground: oklch(0.514 0.198 16.935); --secondary-foreground: oklch(0.32 0.15 275);
--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);
@@ -153,24 +152,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.586 0.222 17.585); --ring: oklch(0.55 0.2 275);
--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.586 0.222 17.585); --chart-1: oklch(0.55 0.2 275);
--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.645 0.215 16.439); --sidebar-primary: oklch(0.64 0.19 275);
--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.645 0.215 16.439); --sidebar-ring: oklch(0.64 0.19 275);
} }
/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...) /* Re-points the shared tokens (--card, --foreground, --muted*, --primary...)
+4 -5
View File
@@ -85,8 +85,7 @@ function LoginForm() {
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Sign in</p> <h1 className="text-3xl font-bold text-foreground">Sign in to your account</h1>
<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">
@@ -147,7 +146,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-primary hover:text-primary/80 transition-colors"> <Link href="/login/forgot" className="text-sm font-medium text-foreground hover:text-foreground/70 transition-colors">
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
@@ -162,7 +161,7 @@ function LoginForm() {
<Button <Button
type="submit" type="submit"
className="w-full mt-8 h-12 text-base font-semibold rounded-full" className="w-1/2 mx-auto mt-8 h-12 text-sm font-medium rounded-full bg-foreground text-background hover:bg-foreground/90"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
> >
{form.formState.isSubmitting ? "Signing in…" : "Sign in"} {form.formState.isSubmitting ? "Signing in…" : "Sign in"}
@@ -177,7 +176,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&apos;t have an account? <Link href="#" className="font-semibold text-primary hover:text-primary/80 transition-colors">Create an account</Link> Don&apos;t have an account? <Link href="#" className="font-semibold text-foreground hover:text-foreground/80 transition-colors">Create an account</Link>
</p> </p>
</div> </div>
</main> </main>
@@ -43,6 +43,7 @@ import {
PieChart, PieChart,
Receipt, Receipt,
ReceiptText, ReceiptText,
Ruler,
Scale, Scale,
ScrollText, ScrollText,
Settings, Settings,
@@ -77,15 +78,7 @@ const navItems: {
expandOnly?: boolean expandOnly?: boolean
icon: LucideIcon icon: LucideIcon
chevron?: boolean chevron?: boolean
children?: { children?: { title: string; code: string; href: string; icon: LucideIcon }[]
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 },
{ {
@@ -453,17 +446,10 @@ export function AppSidebar() {
const visibleItems = loading const visibleItems = loading
? [] ? []
: navItems : navItems
.filter( .filter((item) => bypassCodes.has(item.code) || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
(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) children: bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.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
@@ -116,9 +116,10 @@ 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 "Brand/UOM" if (pathname === "/dashboard/products/brands") return "Brands"
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-sidebar-primary to-primary text-primary-foreground shadow-sm" ? "bg-linear-to-br from-indigo-500 to-violet-600 text-white 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"
)} )}
+2 -2
View File
@@ -8,9 +8,9 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary", default: "bg-black text-white hover:bg-black/90 active:bg-black",
outline: outline:
"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", "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",
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-secondary data-highlighted:text-secondary-foreground" 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"
> >
{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-primary" /> <CheckIcon className="size-4 text-violet-600 dark:text-violet-300" />
</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-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", "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",
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-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", "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",
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-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", "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",
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-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", "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",
className className
)} )}
{...props} {...props}
@@ -1,89 +0,0 @@
"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>
)
}
@@ -1,36 +0,0 @@
"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 }
+2 -2
View File
@@ -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-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", "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",
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-primary" /> <CheckIcon className="pointer-events-none text-violet-600 dark:text-violet-300" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) )
@@ -76,11 +76,10 @@ 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-3.5 text-primary" /> <Icon className="size-4 text-primary" />
</div> </div>
)} )}
</div> </div>
@@ -91,7 +90,7 @@ export function StatCard({
</div> </div>
{hasDelta && ( {hasDelta && (
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-sm">
<span <span
className={cn( className={cn(
"font-semibold", "font-semibold",
+1 -7
View File
@@ -1,14 +1,13 @@
// 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 { EntityStatus, PagedResponse } from "@/types/common" import { 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
} }
@@ -20,9 +19,4 @@ 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 } })
},
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import path from "path"
// //
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that // Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
// this proxy would reject. // this proxy would reject.
const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:6701" const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:5224"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
turbopack: { turbopack: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

-3
View File
@@ -203,9 +203,6 @@ 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 {
-19
View File
@@ -24,25 +24,6 @@ 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