1
0
Code Issues Pull Requests Actions Packages Projects Releases Wiki Activity Security Code Quality

Compare commits

...

10 Commits

Author SHA1 Message Date
b35066835b Add sort for user table and refactor permission page 2025-07-07 16:08:06 +07:00
bd70451320 Add sample data 2025-07-07 10:49:01 +07:00
1865cf4efd refactor source code 2025-07-07 10:39:54 +07:00
ae1a45fe57 Add user page 2025-07-06 21:28:45 +07:00
76ca36ca1b Add mail template layout 2025-07-06 16:27:12 +07:00
815de2932b Add custom page 2025-07-06 16:24:34 +07:00
e8f76f395a Add logon page 2025-07-06 10:18:19 +07:00
99607fb75b add lowdb 2025-07-06 10:18:10 +07:00
1b00f472a2 Add shadcn ui component 2025-07-06 09:59:21 +07:00
ddc122c0a5 Init shadcn ui 2025-07-06 09:43:38 +07:00
68 changed files with 10470 additions and 37 deletions

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

1455
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,19 +9,47 @@
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lowdb": "^7.0.1",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"next": "15.3.5"
"react-hook-form": "^7.60.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.74"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/lowdb": "^1.0.15",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.5",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
}
}

16
src/app/api/auth/route.ts Normal file
View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, initDB, resetDB } from '@/database/database';
export async function POST(req: NextRequest) {
await initDB();
const { username, password } = await req.json();
const user = db.data?.users.find(
(u) => u.username === username && u.password === password && !u.isDeleted
);
if (user) {
// In a real app, return a JWT or session token
return NextResponse.json({ success: true, user: { id: user.id, username: user.username, email: user.email, firstName: user.firstName, lastName: user.lastName } });
} else {
return NextResponse.json({ success: false, message: 'Invalid credentials' }, { status: 401 });
}
}

View File

@@ -0,0 +1,210 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/database/database";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Initialize database
await db.read();
// Parse dependant ID from params
const dependantId = parseInt(params.id);
if (isNaN(dependantId)) {
return NextResponse.json(
{
success: false,
message: "Invalid dependant ID provided",
},
{ status: 400 }
);
}
// Get customer dependants
const customerDependants = db.data!.customerDependants;
// Find the specific dependant
const dependant = customerDependants.find((d) => d.id === dependantId);
if (!dependant) {
return NextResponse.json(
{
success: false,
message: "Dependant not found",
},
{ status: 404 }
);
}
return NextResponse.json(
{
success: true,
data: dependant,
},
{ status: 200 }
);
} catch (error) {
console.error("Error fetching dependant:", error);
return NextResponse.json(
{
success: false,
message: "Failed to fetch dependant",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Initialize database
await db.read();
// Parse dependant ID from params
const dependantId = parseInt(params.id);
if (isNaN(dependantId)) {
return NextResponse.json(
{
success: false,
message: "Invalid dependant ID provided",
},
{ status: 400 }
);
}
// Parse and validate request body
const body = await request.json();
console.log("Received dependant update data:", body);
// Find the dependant to update
const dependantIndex = db.data!.customerDependants.findIndex(
(d) => d.id === dependantId
);
if (dependantIndex === -1) {
return NextResponse.json(
{
success: false,
message: "Dependant not found",
},
{ status: 404 }
);
}
// Update dependant data
const updatedDependant = {
...db.data!.customerDependants[dependantIndex],
firstNameEn: body.dependantInfo.firstNameEn,
lastNameEn: body.dependantInfo.lastNameEn,
originAdd1: body.dependantInfo.originAdd1,
localAdd1: body.dependantInfo.localAdd1,
email: body.dependantContact.email,
mobile: body.dependantContact.mobile,
};
db.data!.customerDependants[dependantIndex] = updatedDependant;
// Save to database
await db.write();
console.log("Dependant updated successfully");
// Return success response
return NextResponse.json(
{
success: true,
message: "Dependant updated successfully",
data: updatedDependant,
},
{ status: 200 }
);
} catch (error) {
console.error("Error updating dependant:", error);
return NextResponse.json(
{
success: false,
message: "Failed to update dependant",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Initialize database
await db.read();
// Parse dependant ID from params
const dependantId = parseInt(params.id);
if (isNaN(dependantId)) {
return NextResponse.json(
{
success: false,
message: "Invalid dependant ID provided",
},
{ status: 400 }
);
}
// Find the dependant to delete
const dependantIndex = db.data!.customerDependants.findIndex(
(d) => d.id === dependantId
);
if (dependantIndex === -1) {
return NextResponse.json(
{
success: false,
message: "Dependant not found",
},
{ status: 404 }
);
}
// Get dependant info before deletion
const dependantToDelete = db.data!.customerDependants[dependantIndex];
// Delete the dependant
db.data!.customerDependants.splice(dependantIndex, 1);
// Save to database
await db.write();
console.log(`Dependant ${dependantId} deleted successfully`);
// Return success response
return NextResponse.json(
{
success: true,
message: "Dependant deleted successfully",
data: dependantToDelete,
},
{ status: 200 }
);
} catch (error) {
console.error("Error deleting dependant:", error);
return NextResponse.json(
{
success: false,
message: "Failed to delete dependant",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,262 @@
import { NextRequest, NextResponse } from "next/server";
import { db, getNextId } from "@/database/database";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Initialize database
await db.read();
// Parse customer ID from params
const { id } = await params;
const customerId = parseInt(id);
if (isNaN(customerId)) {
return NextResponse.json(
{
success: false,
message: "Invalid customer ID provided",
},
{ status: 400 }
);
}
// Get all customers and customer dependants
const customers = db.data!.customers;
const customerDependants = db.data!.customerDependants;
// Find the specific customer
const customer = customers.find((c) => c.id === customerId);
if (!customer) {
return NextResponse.json(
{
success: false,
message: "Customer not found",
},
{ status: 404 }
);
}
// Get customer dependants for this customer
const dependants = customerDependants.filter(
(dependant) => dependant.custId === customerId
);
// Return customer with dependants
const customerWithDependants = {
...customer,
dependants: dependants,
};
return NextResponse.json(
{
success: true,
data: customerWithDependants,
},
{ status: 200 }
);
} catch (error) {
console.error("Error fetching customer:", error);
return NextResponse.json(
{
success: false,
message: "Failed to fetch customer",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Initialize database
await db.read();
// Parse customer ID from params
const { id } = await params;
const customerId = parseInt(id);
if (isNaN(customerId)) {
return NextResponse.json(
{
success: false,
message: "Invalid customer ID provided",
},
{ status: 400 }
);
}
// Parse and validate request body
const body = await request.json();
console.log("Received customer update data:", body);
// Find the customer to update
const customerIndex = db.data!.customers.findIndex((c) => c.id === customerId);
if (customerIndex === -1) {
return NextResponse.json(
{
success: false,
message: "Customer not found",
},
{ status: 404 }
);
}
// Update customer data
const updatedCustomer = {
...db.data!.customers[customerIndex],
firstNameEn: body.customerInfo.firstNameEn,
lastNameEn: body.customerInfo.lastNameEn,
originAdd1: body.customerInfo.originAdd1,
localAdd1: body.customerInfo.localAdd1,
email: body.customerContact.email,
mobile: body.customerContact.mobile,
};
db.data!.customers[customerIndex] = updatedCustomer;
// Handle dependants - remove existing ones and add new ones
db.data!.customerDependants = db.data!.customerDependants.filter(
(dependant) => dependant.custId !== customerId
);
// Add new dependants
const newDependants = [];
if (body.customerDependants && body.customerDependants.length > 0) {
for (const dependant of body.customerDependants) {
const newDependant = {
id: dependant.id && !isNaN(parseInt(dependant.id)) ?
parseInt(dependant.id) :
getNextId(db.data!.customerDependants),
custId: customerId,
firstNameEn: dependant.dependantInfo.firstNameEn,
lastNameEn: dependant.dependantInfo.lastNameEn,
originAdd1: dependant.dependantInfo.originAdd1,
localAdd1: dependant.dependantInfo.localAdd1,
email: dependant.dependantContact.email,
mobile: dependant.dependantContact.mobile,
};
newDependants.push(newDependant);
db.data!.customerDependants.push(newDependant);
}
}
// Save to database
await db.write();
console.log("Customer updated successfully");
// Return success response
return NextResponse.json(
{
success: true,
message: "Customer updated successfully",
data: {
customer: updatedCustomer,
dependants: newDependants,
},
},
{ status: 200 }
);
} catch (error) {
console.error("Error updating customer:", error);
return NextResponse.json(
{
success: false,
message: "Failed to update customer",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Initialize database
await db.read();
// Parse customer ID from params
const { id } = await params;
const customerId = parseInt(id);
if (isNaN(customerId)) {
return NextResponse.json(
{
success: false,
message: "Invalid customer ID provided",
},
{ status: 400 }
);
}
// Find the customer to delete
const customerIndex = db.data!.customers.findIndex((c) => c.id === customerId);
if (customerIndex === -1) {
return NextResponse.json(
{
success: false,
message: "Customer not found",
},
{ status: 404 }
);
}
// Get customer info before deletion
const customerToDelete = db.data!.customers[customerIndex];
// Delete customer dependants first
const dependantsToDelete = db.data!.customerDependants.filter(
(dependant) => dependant.custId === customerId
);
db.data!.customerDependants = db.data!.customerDependants.filter(
(dependant) => dependant.custId !== customerId
);
// Delete the customer
db.data!.customers.splice(customerIndex, 1);
// Save to database
await db.write();
console.log(`Customer ${customerId} and ${dependantsToDelete.length} dependants deleted successfully`);
// Return success response
return NextResponse.json(
{
success: true,
message: `Customer and ${dependantsToDelete.length} dependant(s) deleted successfully`,
data: {
deletedCustomer: customerToDelete,
deletedDependants: dependantsToDelete,
},
},
{ status: 200 }
);
} catch (error) {
console.error("Error deleting customer:", error);
return NextResponse.json(
{
success: false,
message: "Failed to delete customer",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,189 @@
import { NextRequest, NextResponse } from "next/server";
import { db, getNextId } from "@/database/database";
import { Customer, CustomerDependant } from "@/database/database.schema";
import { customerFormSchema } from "@/schemas/customer.schema";
export async function POST(request: NextRequest) {
try {
// Initialize database
await db.read();
// Parse and validate request body
const body = await request.json();
console.log("Received customer data:", body);
const validatedData = customerFormSchema.parse(body);
// Create customer object
const newCustomer: Customer = {
id: getNextId(db.data!.customers),
firstNameEn: validatedData.customerInfo.firstNameEn,
lastNameEn: validatedData.customerInfo.lastNameEn,
email: validatedData.customerContact.email,
mobile: validatedData.customerContact.mobile,
originAdd1: validatedData.customerInfo.originAdd1,
localAdd1: validatedData.customerInfo.localAdd1,
};
// Add customer to database
db.data!.customers.push(newCustomer);
console.log("Customer added to database:", newCustomer);
// Create customer dependants if any
const customerDependants: CustomerDependant[] = [];
if (validatedData.customerDependants && validatedData.customerDependants.length > 0) {
for (const dependant of validatedData.customerDependants) {
const newDependant: CustomerDependant = {
id: getNextId(db.data!.customerDependants),
custId: newCustomer.id,
firstNameEn: dependant.dependantInfo.firstNameEn,
lastNameEn: dependant.dependantInfo.lastNameEn,
email: dependant.dependantContact.email,
mobile: dependant.dependantContact.mobile,
originAdd1: dependant.dependantInfo.originAdd1,
localAdd1: dependant.dependantInfo.localAdd1,
};
customerDependants.push(newDependant);
db.data!.customerDependants.push(newDependant);
}
console.log("Customer dependants added:", customerDependants);
}
// Save to database
await db.write();
console.log("Database updated successfully");
// Return success response
return NextResponse.json(
{
success: true,
message: "Customer and dependants saved successfully",
data: {
customer: newCustomer,
dependants: customerDependants,
},
},
{ status: 201 }
);
} catch (error) {
console.error("Error saving customer:", error);
// Handle validation errors
if (error instanceof Error && error.name === "ZodError") {
return NextResponse.json(
{
success: false,
message: "Invalid data provided",
errors: error.message,
},
{ status: 400 }
);
}
// Handle other errors
return NextResponse.json(
{
success: false,
message: "Failed to save customer",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
// Initialize database
await db.read();
// Get query parameters
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const pageSize = parseInt(searchParams.get("pageSize") || "10");
const search = searchParams.get("search") || "";
const sortBy = searchParams.get("sortBy") || "id";
const sortOrder = searchParams.get("sortOrder") || "asc";
// Get all customers with their dependants
const customers = db.data!.customers;
const customerDependants = db.data!.customerDependants;
// Group dependants by customer ID
let customersWithDependants = customers.map((customer) => ({
...customer,
name: `${customer.firstNameEn} ${customer.lastNameEn}`,
dependants: customerDependants.filter(
(dependant) => dependant.custId === customer.id
),
}));
// Apply search filter
if (search) {
const searchLower = search.toLowerCase();
customersWithDependants = customersWithDependants.filter((customer) =>
customer.firstNameEn.toLowerCase().includes(searchLower) ||
customer.lastNameEn.toLowerCase().includes(searchLower) ||
customer.email.toLowerCase().includes(searchLower) ||
customer.mobile.includes(search)
);
}
// Apply sorting
customersWithDependants.sort((a, b) => {
let aValue: string | number = a[sortBy as keyof typeof a] as string | number;
let bValue: string | number = b[sortBy as keyof typeof b] as string | number;
// Handle special cases
if (sortBy === "name") {
aValue = `${a.firstNameEn} ${a.lastNameEn}`;
bValue = `${b.firstNameEn} ${b.lastNameEn}`;
}
if (typeof aValue === "string") {
aValue = aValue.toLowerCase();
bValue = (bValue as string).toLowerCase();
}
if (sortOrder === "desc") {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
} else {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
}
});
// Calculate pagination
const total = customersWithDependants.length;
const totalPages = Math.ceil(total / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedCustomers = customersWithDependants.slice(startIndex, endIndex);
return NextResponse.json(
{
success: true,
data: paginatedCustomers,
pagination: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
},
{ status: 200 }
);
} catch (error) {
console.error("Error fetching customers:", error);
return NextResponse.json(
{
success: false,
message: "Failed to fetch customers",
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import db from "@/database/db.json";
// GET /api/permissions - Get all available permissions
export async function GET() {
try {
// Get all active permissions
const permissions = db.permissions?.filter(p => p.isActive) || [];
return NextResponse.json(permissions);
} catch (error) {
console.error("Error fetching permissions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,234 @@
import { NextRequest, NextResponse } from "next/server";
import db from "@/database/db.json";
import { writeFileSync } from "fs";
import { join } from "path";
import { UserPermission, User, Permission } from "@/database/database.schema";
// Helper function to write to db.json
function writeDB() {
const dbPath = join(process.cwd(), "src/database/db.json");
writeFileSync(dbPath, JSON.stringify(db, null, 2));
}
// GET /api/user/[id]/permissions - Get all permissions for a user
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
}
// Check if user exists
const user = db.users?.find((u: User) => u.id === userId);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Get user permissions with permission details
const userPermissions = db.userPermissions?.filter((up: UserPermission) => up.userId === userId) || [];
const userPermissionsWithDetails = userPermissions.map((up: UserPermission) => {
const permission = db.permissions?.find((p: Permission) => p.id === up.permissionId);
return {
...up,
permission
};
});
return NextResponse.json(userPermissionsWithDetails);
} catch (error) {
console.error("Error fetching user permissions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// POST /api/user/[id]/permissions - Add permissions to a user
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
}
// Check if user exists
const user = db.users?.find((u: User) => u.id === userId);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { permissionIds } = await request.json();
if (!Array.isArray(permissionIds)) {
return NextResponse.json({ error: "Permission IDs must be an array" }, { status: 400 });
}
// Validate permission IDs
const validPermissionIds = permissionIds.filter((id: number) =>
db.permissions?.find((p: Permission) => p.id === id && p.isActive)
);
if (validPermissionIds.length !== permissionIds.length) {
return NextResponse.json({ error: "Some permission IDs are invalid" }, { status: 400 });
}
// Remove existing permissions for this user
if (db.userPermissions) {
db.userPermissions = db.userPermissions.filter((up: UserPermission) => up.userId !== userId);
}
// Add new permissions
const currentUserPermissions = db.userPermissions || [];
const maxId = currentUserPermissions.length > 0 ? Math.max(...currentUserPermissions.map((up: UserPermission) => up.id)) : 0;
const newUserPermissions: UserPermission[] = validPermissionIds.map((permissionId: number, index: number) => ({
id: maxId + index + 1,
userId,
permissionId
}));
if (db.userPermissions) {
db.userPermissions.push(...newUserPermissions);
} else {
db.userPermissions = newUserPermissions;
}
writeDB();
// Get updated permissions with details
const updatedPermissions = newUserPermissions.map((up: UserPermission) => {
const permission = db.permissions?.find((p: Permission) => p.id === up.permissionId);
return {
...up,
permission
};
});
return NextResponse.json(updatedPermissions);
} catch (error) {
console.error("Error updating user permissions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// PUT /api/user/[id]/permissions - Update permissions for a user
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
}
// Check if user exists
const user = db.users?.find((u: User) => u.id === userId);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { permissionIds } = await request.json();
if (!Array.isArray(permissionIds)) {
return NextResponse.json({ error: "Permission IDs must be an array" }, { status: 400 });
}
// Validate permission IDs
const validPermissionIds = permissionIds.filter((id: number) =>
db.permissions?.find((p: Permission) => p.id === id && p.isActive)
);
if (validPermissionIds.length !== permissionIds.length) {
return NextResponse.json({ error: "Some permission IDs are invalid" }, { status: 400 });
}
// Remove existing permissions for this user
if (db.userPermissions) {
db.userPermissions = db.userPermissions.filter((up: UserPermission) => up.userId !== userId);
}
// Add new permissions
const currentUserPermissions = db.userPermissions || [];
const maxId = currentUserPermissions.length > 0 ? Math.max(...currentUserPermissions.map((up: UserPermission) => up.id)) : 0;
const newUserPermissions: UserPermission[] = validPermissionIds.map((permissionId: number, index: number) => ({
id: maxId + index + 1,
userId,
permissionId
}));
if (db.userPermissions) {
db.userPermissions.push(...newUserPermissions);
} else {
db.userPermissions = newUserPermissions;
}
writeDB();
// Get updated permissions with details
const updatedPermissions = newUserPermissions.map((up: UserPermission) => {
const permission = db.permissions?.find((p: Permission) => p.id === up.permissionId);
return {
...up,
permission
};
});
return NextResponse.json(updatedPermissions);
} catch (error) {
console.error("Error updating user permissions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// DELETE /api/user/[id]/permissions - Remove all permissions from a user
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = parseInt(params.id);
if (isNaN(userId)) {
return NextResponse.json({ error: "Invalid user ID" }, { status: 400 });
}
// Check if user exists
const user = db.users?.find((u: User) => u.id === userId);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Remove all permissions for this user
if (db.userPermissions) {
db.userPermissions = db.userPermissions.filter((up: UserPermission) => up.userId !== userId);
}
writeDB();
return NextResponse.json({ message: "All permissions removed successfully" });
} catch (error) {
console.error("Error removing user permissions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,175 @@
import { NextResponse } from "next/server";
import db from "@/database/db.json";
import { writeFileSync } from "fs";
import { join } from "path";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const userId = parseInt(id);
if (isNaN(userId)) {
return NextResponse.json(
{ success: false, message: "Invalid user ID" },
{ status: 400 }
);
}
const user = db.users.find(u => u.id === userId && !u.isDeleted);
if (!user) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 }
);
}
// Return user without password
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _, ...userResponse } = user;
return NextResponse.json({
success: true,
data: userResponse,
});
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ success: false, message: "Failed to fetch user" },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const userId = parseInt(id);
if (isNaN(userId)) {
return NextResponse.json(
{ success: false, message: "Invalid user ID" },
{ status: 400 }
);
}
const body = await request.json();
const { username, email, firstName, lastName, password } = body;
// Validate required fields
if (!username || !email || !firstName || !lastName) {
return NextResponse.json(
{ success: false, message: "Username, email, firstName, and lastName are required" },
{ status: 400 }
);
}
// Find user
const userIndex = db.users.findIndex(u => u.id === userId && !u.isDeleted);
if (userIndex === -1) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 }
);
}
// Check if username or email already exists (excluding current user)
const existingUser = db.users.find(
(user) =>
user.id !== userId &&
(user.username === username || user.email === email) &&
!user.isDeleted
);
if (existingUser) {
return NextResponse.json(
{ success: false, message: "Username or email already exists" },
{ status: 400 }
);
}
// Update user
const updatedUser = {
...db.users[userIndex],
username,
email,
firstName,
lastName,
...(password && { password }), // Only update password if provided
};
db.users[userIndex] = updatedUser;
// Write to file
const dbPath = join(process.cwd(), "src/database/db.json");
writeFileSync(dbPath, JSON.stringify(db, null, 2));
// Return success response (without password)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _, ...userResponse } = updatedUser;
return NextResponse.json({
success: true,
data: userResponse,
message: "User updated successfully",
});
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ success: false, message: "Failed to update user" },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const userId = parseInt(id);
if (isNaN(userId)) {
return NextResponse.json(
{ success: false, message: "Invalid user ID" },
{ status: 400 }
);
}
// Find user
const userIndex = db.users.findIndex(u => u.id === userId && !u.isDeleted);
if (userIndex === -1) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 }
);
}
// Soft delete user
db.users[userIndex].isDeleted = true;
// Write to file
const dbPath = join(process.cwd(), "src/database/db.json");
writeFileSync(dbPath, JSON.stringify(db, null, 2));
return NextResponse.json({
success: true,
message: "User deleted successfully",
});
} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ success: false, message: "Failed to delete user" },
{ status: 500 }
);
}
}

150
src/app/api/user/route.ts Normal file
View File

@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import db from "@/database/db.json";
import { writeFileSync } from "fs";
import { join } from "path";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "10", 10);
const search = (searchParams.get("search") || "").toLowerCase();
const sortBy = searchParams.get("sortBy") || "id";
const sortOrder = searchParams.get("sortOrder") || "asc";
const users = db.users || [];
// Filter users based on search query
let filtered = users.filter(user => !user.isDeleted);
if (search) {
filtered = filtered.filter(
(u) =>
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search) ||
u.email.toLowerCase().includes(search) ||
u.username.toLowerCase().includes(search)
);
}
// Sort users
filtered.sort((a, b) => {
let aValue: string | number | boolean = a[sortBy as keyof typeof a];
let bValue: string | number | boolean = b[sortBy as keyof typeof b];
// Handle special cases
if (sortBy === "fullName") {
aValue = `${a.firstName} ${a.lastName}`;
bValue = `${b.firstName} ${b.lastName}`;
}
// Convert to comparable values
const aCompare = typeof aValue === "string" ? aValue.toLowerCase() : aValue;
const bCompare = typeof bValue === "string" ? bValue.toLowerCase() : bValue;
if (sortOrder === "desc") {
return aCompare > bCompare ? -1 : aCompare < bCompare ? 1 : 0;
} else {
return aCompare < bCompare ? -1 : aCompare > bCompare ? 1 : 0;
}
});
const total = filtered.length;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const data = filtered.slice(start, end);
const pagination = {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
return NextResponse.json({
success: true,
data,
pagination,
});
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json({
success: false,
data: [],
pagination: {
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
message: "Failed to fetch users",
});
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, email, firstName, lastName, password } = body;
// Validate required fields
if (!username || !email || !firstName || !lastName || !password) {
return NextResponse.json(
{ success: false, message: "All fields are required" },
{ status: 400 }
);
}
// Check if username or email already exists
const existingUser = db.users.find(
(user) =>
(user.username === username || user.email === email) &&
!user.isDeleted
);
if (existingUser) {
return NextResponse.json(
{ success: false, message: "Username or email already exists" },
{ status: 400 }
);
}
// Generate new ID
const newId = Math.max(...db.users.map(u => u.id), 0) + 1;
// Create new user
const newUser = {
id: newId,
username,
email,
firstName,
lastName,
password, // Note: In production, this should be hashed
isDeleted: false,
};
// Add to database
db.users.push(newUser);
// Write to file
const dbPath = join(process.cwd(), "src/database/db.json");
writeFileSync(dbPath, JSON.stringify(db, null, 2));
// Return success response (without password)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _, ...userResponse } = newUser;
return NextResponse.json(
{ success: true, data: userResponse, message: "User created successfully" },
{ status: 201 }
);
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ success: false, message: "Failed to create user" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import axios from "axios";
import { useState } from "react";
import { loginSchema } from "@/schemas/auth.schema";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() {
const form = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { username: "", password: "" },
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: LoginForm) => {
setError("");
setLoading(true);
try {
const res = await axios.post("/api/auth", data);
if (res.data.success) {
// You can redirect or set session here
window.location.href = "/modules/user"; // Redirect to user page on successful login
} else {
setError(res.data.message || "Login failed");
}
} catch (e: any) {
setError(e.response?.data?.message || "Login failed");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow">
<h2 className="mb-6 text-center text-2xl font-bold">Login</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
{...form.register("username")}
aria-invalid={!!form.formState.errors.username}
disabled={loading}
/>
{form.formState.errors.username && (
<p className="mt-1 text-xs text-red-500">
{form.formState.errors.username.message as string}
</p>
)}
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...form.register("password")}
aria-invalid={!!form.formState.errors.password}
disabled={loading}
/>
{form.formState.errors.password && (
<p className="mt-1 text-xs text-red-500">
{form.formState.errors.password.message as string}
</p>
)}
</div>
{error && <div className="text-red-500 text-sm">{error}</div>}
<button
type="submit"
className="w-full rounded bg-blue-600 py-2 px-4 font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
disabled={loading}
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
</Form>
</div>
</div>
);
}

View File

@@ -1,26 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
@@ -28,6 +29,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster />
</body>
</html>
);

View File

@@ -0,0 +1,630 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useParams, useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
customerFormSchema,
customerDependantFormSchema,
type CustomerForm,
type CustomerDependantForm,
} from "@/schemas/customer.schema";
import axios from "axios";
import { Header } from "@/components/common/header";
export default function CustomerEditPage() {
const params = useParams();
const router = useRouter();
const customerId = params.id as string;
const [activeTab, setActiveTab] = useState("info");
const [dependantDialogOpen, setDependantDialogOpen] = useState(false);
const [dependantDialogTab, setDependantDialogTab] = useState("info");
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [editingDependant, setEditingDependant] = useState<CustomerDependantForm | null>(null);
// Main form
const form = useForm<CustomerForm>({
resolver: zodResolver(customerFormSchema),
defaultValues: {
customerInfo: {
firstNameEn: "",
lastNameEn: "",
originAdd1: "",
localAdd1: "",
},
customerContact: {
email: "",
mobile: "",
},
customerDependants: [],
},
});
// Dependant dialog form
const dependantForm = useForm<CustomerDependantForm>({
resolver: zodResolver(customerDependantFormSchema),
defaultValues: {
dependantInfo: {
firstNameEn: "",
lastNameEn: "",
originAdd1: "",
localAdd1: "",
},
dependantContact: {
email: "",
mobile: "",
},
},
});
const watchedDependants = form.watch("customerDependants");
// Fetch customer data on component mount
useEffect(() => {
const fetchCustomerData = async () => {
try {
setIsLoading(true);
const response = await axios.get(`/api/customer/${customerId}`);
if (response.data.success) {
const customerData = response.data.data;
// Populate form with customer data
form.setValue("customerInfo.firstNameEn", customerData.firstNameEn);
form.setValue("customerInfo.lastNameEn", customerData.lastNameEn);
form.setValue("customerInfo.originAdd1", customerData.originAdd1);
form.setValue("customerInfo.localAdd1", customerData.localAdd1);
form.setValue("customerContact.email", customerData.email);
form.setValue("customerContact.mobile", customerData.mobile);
// Convert dependants to the expected format
const dependants: CustomerDependantForm[] = customerData.dependants.map((dep: {
id: number;
firstNameEn: string;
lastNameEn: string;
email: string;
mobile: string;
originAdd1: string;
localAdd1: string;
}) => ({
id: dep.id,
dependantInfo: {
firstNameEn: dep.firstNameEn,
lastNameEn: dep.lastNameEn,
originAdd1: dep.originAdd1,
localAdd1: dep.localAdd1,
},
dependantContact: {
email: dep.email,
mobile: dep.mobile,
},
}));
form.setValue("customerDependants", dependants);
toast.success("Customer data loaded successfully");
} else {
toast.error(response.data.message || "Failed to load customer data");
}
} catch (error) {
console.error("Error fetching customer data:", error);
toast.error("Failed to load customer data");
} finally {
setIsLoading(false);
}
};
if (customerId) {
fetchCustomerData();
}
}, [customerId, form]);
const onSubmit = async (data: CustomerForm) => {
try {
setIsSaving(true);
console.log("Submitting customer data:", data);
const response = await axios.put(`/api/customer/${customerId}`, data);
console.log("API response:", response.data);
if (response.data.success) {
toast.success("Customer updated successfully!");
router.push("/modules/customer");
} else {
throw new Error(response.data.message || "Failed to update customer");
}
} catch (error) {
console.error("Error updating customer:", error);
toast.error("Failed to update customer. Please try again.");
} finally {
setIsSaving(false);
}
};
// handleSaveClick removed: now using form submit
const onDependantSubmit = async (data: CustomerDependantForm) => {
try {
// Validate the entire dependant form
const isValid = await dependantForm.trigger();
if (!isValid) {
toast.error("Please fill in all required fields.");
return;
}
const currentDependants = form.getValues("customerDependants");
if (editingDependant) {
// Update existing dependant
const updatedDependants = currentDependants.map((dep) =>
dep.id === editingDependant.id
? {
...dep,
firstNameEn: data.dependantInfo.firstNameEn,
lastNameEn: data.dependantInfo.lastNameEn,
originAdd1: data.dependantInfo.originAdd1,
localAdd1: data.dependantInfo.localAdd1,
email: data.dependantContact.email,
mobile: data.dependantContact.mobile,
}
: dep
);
form.setValue("customerDependants", updatedDependants);
toast.success("Dependant updated successfully!");
} else {
// Add new dependant - just add to the table, don't submit parent form
const newDependant: CustomerDependantForm = {
id: Date.now(),
dependantInfo: {
firstNameEn: data.dependantInfo.firstNameEn,
lastNameEn: data.dependantInfo.lastNameEn,
originAdd1: data.dependantInfo.originAdd1,
localAdd1: data.dependantInfo.localAdd1,
},
dependantContact: {
email: data.dependantContact.email,
mobile: data.dependantContact.mobile,
},
};
// Add new row to dependant table without submitting parent form
form.setValue("customerDependants", [...currentDependants, newDependant]);
toast.success("Dependant added successfully!");
}
// Reset dependant form and close dialog
dependantForm.reset();
setDependantDialogOpen(false);
setDependantDialogTab("info");
setEditingDependant(null);
} catch (error) {
console.error("Error handling dependant:", error);
toast.error("Failed to save dependant. Please try again.");
}
};
const handleEditDependant = (dependant: CustomerDependantForm) => {
setEditingDependant(dependant);
dependantForm.setValue("dependantInfo.firstNameEn", dependant.dependantInfo.firstNameEn);
dependantForm.setValue("dependantInfo.lastNameEn", dependant.dependantInfo.lastNameEn);
dependantForm.setValue("dependantInfo.originAdd1", dependant.dependantInfo.originAdd1);
dependantForm.setValue("dependantInfo.localAdd1", dependant.dependantInfo.localAdd1);
dependantForm.setValue("dependantContact.email", dependant.dependantContact.email);
dependantForm.setValue("dependantContact.mobile", dependant.dependantContact.mobile);
setDependantDialogOpen(true);
setDependantDialogTab("info");
};
const handleAddDependant = () => {
setEditingDependant(null);
dependantForm.reset();
setDependantDialogOpen(true);
setDependantDialogTab("info");
};
const removeDependant = (dependantId: number | undefined) => {
if (!dependantId) return;
const currentDependants = form.getValues("customerDependants");
const updatedDependants = currentDependants.filter(
(dependant) => dependant.id !== dependantId
);
form.setValue("customerDependants", updatedDependants);
toast.success("Dependant removed successfully!");
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span>Loading customer data...</span>
</div>
</div>
);
}
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "Customer Management", href: "/modules/customer" },
{ title: "Edit Customer", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-4">
<Button
variant="outline"
onClick={() => router.push("/modules/customer")}
className="flex items-center space-x-2"
>
<ArrowLeft className="h-4 w-4" />
<span>Back to Customers</span>
</Button>
<h1 className="text-2xl font-bold">Edit Customer</h1>
</div>
<Button
type="submit"
form="customer-edit-form"
disabled={isSaving}
className="flex items-center space-x-2"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Saving...</span>
</>
) : (
<>
<Save className="h-4 w-4" />
<span>Save Changes</span>
</>
)}
</Button>
</div>
<Form {...form}>
<form id="customer-edit-form" className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="info">Customer Info</TabsTrigger>
<TabsTrigger value="contact">Contact Details</TabsTrigger>
<TabsTrigger value="dependants">Dependants</TabsTrigger>
</TabsList>
<TabsContent value="info" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Customer Information</CardTitle>
<CardDescription>Edit the customer&apos;s basic information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerInfo.firstNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>First Name (English)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerInfo.lastNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name (English)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerInfo.originAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Address</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerInfo.localAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Local Address</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="contact" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Contact Details</CardTitle>
<CardDescription>Edit the customer&apos;s contact information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="customerContact.email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerContact.mobile"
render={({ field }) => (
<FormItem>
<FormLabel>Mobile</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="dependants" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Customer Dependants</CardTitle>
<CardDescription>Manage customer dependants</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
Dependants ({watchedDependants.length})
</h3>
<Dialog open={dependantDialogOpen} onOpenChange={setDependantDialogOpen}>
<DialogTrigger asChild>
<Button onClick={handleAddDependant} className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Dependant</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{editingDependant ? "Edit Dependant" : "Add New Dependant"}
</DialogTitle>
</DialogHeader>
<Form {...dependantForm}>
<form>
<Tabs value={dependantDialogTab} onValueChange={setDependantDialogTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="info">Personal Info</TabsTrigger>
<TabsTrigger value="contact">Contact Details</TabsTrigger>
</TabsList>
<TabsContent value="info" className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={dependantForm.control}
name="dependantInfo.firstNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>First Name (English)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={dependantForm.control}
name="dependantInfo.lastNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name (English)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={dependantForm.control}
name="dependantInfo.originAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Address</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={dependantForm.control}
name="dependantInfo.localAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Local Address</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="contact" className="space-y-4 mt-4">
<FormField
control={dependantForm.control}
name="dependantContact.email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={dependantForm.control}
name="dependantContact.mobile"
render={({ field }) => (
<FormItem>
<FormLabel>Mobile</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-2 mt-6">
<Button
type="button"
variant="outline"
onClick={() => {
setDependantDialogOpen(false);
setEditingDependant(null);
dependantForm.reset();
}}
>
Cancel
</Button>
<Button
type="button"
onClick={async () => {
const isValid = await dependantForm.trigger();
if (isValid) {
const formData = dependantForm.getValues();
onDependantSubmit(formData);
} else {
toast.error("Please fill in all required fields.");
}
}}
>
{editingDependant ? "Update Dependant" : "Add Dependant"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{watchedDependants.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Mobile</TableHead>
<TableHead>Origin Address</TableHead>
<TableHead>Local Address</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{watchedDependants.map((dependant) => (
<TableRow key={dependant.id}>
<TableCell>
{dependant.dependantInfo.firstNameEn} {dependant.dependantInfo.lastNameEn}
</TableCell>
<TableCell>{dependant.dependantContact.email}</TableCell>
<TableCell>{dependant.dependantContact.mobile}</TableCell>
<TableCell>{dependant.dependantInfo.originAdd1}</TableCell>
<TableCell>{dependant.dependantInfo.localAdd1}</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleEditDependant(dependant)}
>
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeDependant(dependant.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-gray-500">
No dependants added yet. Click &quot;Add Dependant&quot; to get started.
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</form>
</Form>
</div>
</div>
);
}

View File

@@ -0,0 +1,588 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import {
customerFormSchema,
customerDependantFormSchema,
type CustomerForm,
type CustomerDependantForm,
} from "@/schemas/customer.schema";
import axios from "axios";
import { Header } from "@/components/common/header";
export default function CustomerAddPage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState("info");
const [dependantDialogOpen, setDependantDialogOpen] = useState(false);
const [dependantDialogTab, setDependantDialogTab] = useState("info");
// Main form
const form = useForm<CustomerForm>({
resolver: zodResolver(customerFormSchema),
defaultValues: {
customerInfo: {
firstNameEn: "",
lastNameEn: "",
originAdd1: "",
localAdd1: "",
},
customerContact: {
email: "",
mobile: "",
},
customerDependants: [],
},
});
// Dependant dialog form
const dependantForm = useForm<CustomerDependantForm>({
resolver: zodResolver(customerDependantFormSchema),
defaultValues: {
dependantInfo: {
firstNameEn: "",
lastNameEn: "",
originAdd1: "",
localAdd1: "",
},
dependantContact: {
email: "",
mobile: "",
},
},
});
const watchedDependants = form.watch("customerDependants");
const onSubmit = async (data: CustomerForm) => {
try {
const response = await axios.post("/api/customer", data);
if (response.status !== 200 && response.status !== 201) {
throw new Error(response.data?.message || "Failed to save customer");
}
console.log("Customer saved successfully:", response.data);
toast.success("Customer added successfully!");
// Reset form after successful submission
form.reset();
// Redirect to customer page
router.push("/modules/customer");
} catch (error) {
console.error("Error submitting form:", error);
toast.error("Failed to add customer. Please try again.");
}
};
const onDependantSubmit = async (data: CustomerDependantForm) => {
try {
// Validate the entire dependant form
const isValid = await dependantForm.trigger();
if (!isValid) {
toast.error("Please fill in all required fields.");
return;
}
const newDependant: CustomerDependantForm = {
id: Date.now(), // Generate unique ID
dependantInfo: {
firstNameEn: data.dependantInfo.firstNameEn,
lastNameEn: data.dependantInfo.lastNameEn,
originAdd1: data.dependantInfo.originAdd1,
localAdd1: data.dependantInfo.localAdd1,
},
dependantContact: {
email: data.dependantContact.email,
mobile: data.dependantContact.mobile,
},
};
const currentDependants = form.getValues("customerDependants");
form.setValue("customerDependants", [...currentDependants, newDependant]);
// Reset dependant form and close dialog
dependantForm.reset();
setDependantDialogOpen(false);
setDependantDialogTab("info");
toast.success("Dependant added successfully!");
} catch (error) {
console.error("Error adding dependant:", error);
toast.error("Failed to add dependant. Please try again.");
}
};
const removeDependant = (dependantId: number) => {
const currentDependants = form.getValues("customerDependants");
const updatedDependants = currentDependants.filter(dep => dep.id !== dependantId);
form.setValue("customerDependants", updatedDependants);
toast.success("Dependant removed successfully!");
};
const handleNextTab = async () => {
if (activeTab === "info") {
// Validate customer info fields before proceeding
const isValid = await form.trigger([
"customerInfo.firstNameEn",
"customerInfo.lastNameEn",
"customerInfo.originAdd1",
"customerInfo.localAdd1"
]);
if (isValid) {
setActiveTab("contact");
} else {
toast.error("Please fill in all required fields in Customer Info tab.");
}
} else if (activeTab === "contact") {
// Validate customer contact fields before proceeding
const isValid = await form.trigger(["customerContact.email", "customerContact.mobile"]);
if (isValid) {
setActiveTab("dependants");
} else {
toast.error("Please fill in all required fields in Customer Contact tab.");
}
}
};
const handlePreviousTab = () => {
if (activeTab === "contact") {
setActiveTab("info");
} else if (activeTab === "dependants") {
setActiveTab("contact");
}
};
const isTabValid = (tabName: string) => {
const values = form.getValues();
const errors = form.formState.errors;
if (tabName === "info") {
return (
values.customerInfo?.firstNameEn?.trim() !== "" &&
values.customerInfo?.lastNameEn?.trim() !== "" &&
values.customerInfo?.originAdd1?.trim() !== "" &&
values.customerInfo?.localAdd1?.trim() !== "" &&
!errors.customerInfo?.firstNameEn &&
!errors.customerInfo?.lastNameEn &&
!errors.customerInfo?.originAdd1 &&
!errors.customerInfo?.localAdd1
);
} else if (tabName === "contact") {
return (
values.customerContact?.email?.trim() !== "" &&
values.customerContact?.mobile?.trim() !== "" &&
!errors.customerContact?.email &&
!errors.customerContact?.mobile
);
}
return true;
};
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "Customer Management", href: "/modules/customer" },
{ title: "Add Customer", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-4">
<Button
variant="outline"
onClick={() => router.push("/modules/customer")}
className="flex items-center space-x-2"
>
<ArrowLeft className="h-4 w-4" />
<span>Back to Customers</span>
</Button>
<h1 className="text-2xl font-bold">Add New Customer</h1>
</div>
</div>
<Card>
<CardHeader>
<CardDescription>
Please fill in the customer information across the three tabs below.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Tabs value={activeTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger
value="info"
className={!isTabValid("info") ? "text-red-500" : ""}
>
Customer Info
</TabsTrigger>
<TabsTrigger
value="contact"
className={!isTabValid("contact") ? "text-red-500" : ""}
disabled={!isTabValid("info")}
>
Customer Contact
</TabsTrigger>
<TabsTrigger
value="dependants"
disabled={!isTabValid("info") || !isTabValid("contact")}
>
Customer Dependants
</TabsTrigger>
</TabsList>
{/* Tab 1: Customer Info */}
<TabsContent value="info" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerInfo.firstNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>First Name (English)</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerInfo.lastNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name (English)</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerInfo.originAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Address</FormLabel>
<FormControl>
<Input placeholder="Enter origin address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerInfo.localAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Local Address</FormLabel>
<FormControl>
<Input placeholder="Enter local address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button type="button" onClick={handleNextTab}>
Next
</Button>
</div>
</TabsContent>
{/* Tab 2: Customer Contact */}
<TabsContent value="contact" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerContact.email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter email address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerContact.mobile"
render={({ field }) => (
<FormItem>
<FormLabel>Mobile Number</FormLabel>
<FormControl>
<Input placeholder="Enter mobile number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-between">
<Button type="button" variant="outline" onClick={handlePreviousTab}>
Previous
</Button>
<Button type="button" onClick={handleNextTab}>
Next
</Button>
</div>
</TabsContent>
{/* Tab 3: Customer Dependants */}
<TabsContent value="dependants" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Customer Dependants</h3>
<Dialog open={dependantDialogOpen} onOpenChange={setDependantDialogOpen}>
<DialogTrigger asChild>
<Button type="button" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add Dependant
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add New Dependant</DialogTitle>
</DialogHeader>
<Form {...dependantForm}>
<form onSubmit={dependantForm.handleSubmit(onDependantSubmit)} className="space-y-4">
<Tabs value={dependantDialogTab} onValueChange={setDependantDialogTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="info">Dependant Info</TabsTrigger>
<TabsTrigger value="contact">Dependant Contact</TabsTrigger>
</TabsList>
<TabsContent value="info" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={dependantForm.control}
name="dependantInfo.firstNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>First Name (English)</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={dependantForm.control}
name="dependantInfo.lastNameEn"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name (English)</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={dependantForm.control}
name="dependantInfo.originAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Origin Address</FormLabel>
<FormControl>
<Input placeholder="Enter origin address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={dependantForm.control}
name="dependantInfo.localAdd1"
render={({ field }) => (
<FormItem>
<FormLabel>Local Address</FormLabel>
<FormControl>
<Input placeholder="Enter local address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={async () => {
const isValid = await dependantForm.trigger([
"dependantInfo.firstNameEn",
"dependantInfo.lastNameEn",
"dependantInfo.originAdd1",
"dependantInfo.localAdd1"
]);
if (isValid) {
setDependantDialogTab("contact");
} else {
toast.error("Please fill in all required fields in Dependant Info tab.");
}
}}
>
Next
</Button>
</div>
</TabsContent>
<TabsContent value="contact" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={dependantForm.control}
name="dependantContact.email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter email address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={dependantForm.control}
name="dependantContact.mobile"
render={({ field }) => (
<FormItem>
<FormLabel>Mobile Number</FormLabel>
<FormControl>
<Input placeholder="Enter mobile number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-start">
<Button
type="button"
variant="outline"
onClick={() => setDependantDialogTab("info")}
>
Previous
</Button>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setDependantDialogOpen(false)}
>
Cancel
</Button>
<Button
type="button"
onClick={async () => {
const isValid = await dependantForm.trigger();
if (isValid) {
const formData = dependantForm.getValues();
onDependantSubmit(formData);
} else {
toast.error("Please fill in all required fields.");
}
}}
>
Add Dependant
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{watchedDependants.length > 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Mobile</TableHead>
<TableHead>Origin Address</TableHead>
<TableHead>Local Address</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{watchedDependants.map((dependant) => (
<TableRow key={dependant.id}>
<TableCell>{dependant.dependantInfo.firstNameEn}</TableCell>
<TableCell>{dependant.dependantInfo.lastNameEn}</TableCell>
<TableCell>{dependant.dependantContact.email}</TableCell>
<TableCell>{dependant.dependantContact.mobile}</TableCell>
<TableCell>{dependant.dependantInfo.originAdd1}</TableCell>
<TableCell>{dependant.dependantInfo.localAdd1}</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDependant(dependant.id!)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center text-muted-foreground py-8">
No dependants added yet. Click &quot;Add Dependant&quot; to add one.
</div>
)}
<div className="flex justify-between">
<Button type="button" variant="outline" onClick={handlePreviousTab}>
Previous
</Button>
<Button type="submit">
Save Customer
</Button>
</div>
</TabsContent>
</Tabs>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Loader2, Users, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import { ServerDataTable } from "@/components/ui/server-data-table";
import { Customer, createCustomerColumns } from "@/components/customers/customer-columns";
import { Header } from "@/components/common/header";
interface PaginationInfo {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
interface CustomerResponse {
success: boolean;
data: Customer[];
pagination: PaginationInfo;
message?: string;
}
export default function CustomerPage() {
const router = useRouter();
const [customers, setCustomers] = useState<Customer[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [query, setQuery] = useState({
page: 1,
pageSize: 10,
search: "",
sortBy: "id",
sortOrder: "asc" as "asc" | "desc",
});
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
});
// Initialize search and sorting state to match query
const [searchValue, setSearchValue] = useState(query.search);
const [sorting, setSorting] = useState<{ id: string; desc: boolean }[]>(
query.sortBy ? [{ id: query.sortBy, desc: query.sortOrder === "desc" }] : []
);
const fetchCustomers = useCallback(async () => {
try {
setIsLoading(true);
const params = new URLSearchParams({
page: query.page.toString(),
pageSize: query.pageSize.toString(),
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
const response = await axios.get<CustomerResponse>(`/api/customer?${params}`);
if (response.data.success) {
setCustomers(response.data.data);
setPagination(response.data.pagination);
} else {
toast.error(response.data.message || "Failed to load customers");
}
} catch (error) {
console.error("Error fetching customers:", error);
toast.error("Failed to load customers");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [query]);
// Fetch customers when query changes
useEffect(() => {
fetchCustomers();
}, [fetchCustomers]);
const handleAddCustomer = useCallback(() => {
router.push("/modules/customer/add");
}, [router]);
const handleEditCustomer = useCallback((customerId: number) => {
router.push(`/modules/customer/${customerId}`);
}, [router]);
const handleDeleteCustomer = useCallback(async (customerId: number) => {
// Find the customer to get their name for confirmation
const customerToDelete = customers.find(c => c.id === customerId);
const customerName = customerToDelete ? `${customerToDelete.firstNameEn} ${customerToDelete.lastNameEn}` : `Customer #${customerId}`;
if (!window.confirm(`Are you sure you want to delete ${customerName} and all their dependants? This action cannot be undone.`)) {
return;
}
try {
const response = await axios.delete(`/api/customer/${customerId}`);
if (response.data.success) {
toast.success(response.data.message || "Customer deleted successfully");
// Refresh the customer list
await fetchCustomers();
} else {
toast.error(response.data.message || "Failed to delete customer");
}
} catch (error) {
console.error("Error deleting customer:", error);
toast.error("Failed to delete customer");
}
}, [fetchCustomers, customers]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await fetchCustomers();
toast.success("Customer list refreshed");
}, [fetchCustomers]);
const handlePaginationChange = useCallback((page: number, pageSize: number) => {
setQuery((prev) => ({ ...prev, page, pageSize }));
}, []);
// Search handler - will be called from ServerDataTable after debounce
const handleSearchChange = useCallback((search: string) => {
setQuery((prev) => ({ ...prev, search, page: 1 }));
}, []);
// Sorting handler - will be called from ServerDataTable
const handleSortingChange = useCallback((sortByField: string, sortOrderValue: "asc" | "desc") => {
setQuery((prev) => ({ ...prev, sortBy: sortByField, sortOrder: sortOrderValue }));
setSorting([{ id: sortByField, desc: sortOrderValue === "desc" }]);
}, []);
const columns = useMemo(() => createCustomerColumns({
onEdit: handleEditCustomer,
onDelete: handleDeleteCustomer,
}), [handleEditCustomer, handleDeleteCustomer]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span>Loading customers...</span>
</div>
</div>
);
}
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "Customer Management", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Users className="h-8 w-8" />
<h1 className="text-3xl font-bold">Customer Management</h1>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
disabled={isRefreshing}
className="flex items-center space-x-2"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
<span>Refresh</span>
</Button>
<Button onClick={handleAddCustomer} className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Customer</span>
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Customers</CardTitle>
<CardDescription>
Manage your customer database ({pagination.total} total)
</CardDescription>
</CardHeader>
<CardContent>
{pagination.total > 0 ? (
<ServerDataTable
columns={columns}
data={customers}
pagination={pagination}
searchKey="name"
searchPlaceholder="Search customers by name..."
isLoading={isLoading}
onPaginationChange={handlePaginationChange}
onSearchChange={handleSearchChange}
onSortingChange={handleSortingChange}
searchValue={searchValue}
sorting={sorting}
setSearchValue={setSearchValue}
setSorting={setSorting}
/>
) : (
<div className="text-center py-8 text-gray-500">
<Users className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium">No customers found</p>
<p className="text-sm">Get started by adding your first customer</p>
<Button onClick={handleAddCustomer} className="mt-4">
<Plus className="h-4 w-4 mr-2" />
Add Customer
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar";
export default function ModulesLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<main>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
</main>
);
}

View File

@@ -0,0 +1,237 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { ChevronDown, ChevronUp, Download, X, Settings, Mail, Send } from "lucide-react"
import { Header } from "@/components/common/header"
export default function MailTemplatePage() {
const [bodyExpanded, setBodyExpanded] = useState(true)
const [footerExpanded, setFooterExpanded] = useState(true)
const [bodyText, setBodyText] = useState("HBS健康診断受診者 各位\n\nいつも、お世話になっております。")
const [footerText, setFooterText] = useState("※本件に関するお問い合わせは、\n健康管理室\nメールアドレス")
const attachments = [
"1-1_2021年度健康診断受診のお知らせ.doc",
"1-2_2021年度受診料金.doc",
"1-3_【別紙】健康診断受診案内.xlsx",
"",
"",
]
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "Mail Template", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Mail className="h-8 w-8" />
<h1 className="text-3xl font-bold">Mail Template</h1>
</div>
<div className="flex items-center space-x-2">
<Button className="bg-slate-400 hover:bg-slate-500 text-white">
<Settings className="w-4 h-4 mr-2" />
Registration
</Button>
<Button className="bg-orange-400 hover:bg-orange-500 text-white">
<Send className="w-4 h-4 mr-2" />
Send Test Email
</Button>
<Button className="bg-gray-400 hover:bg-gray-500 text-white">
<Mail className="w-4 h-4 mr-2" />
Send E-mail
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Email Template Configuration</CardTitle>
<CardDescription>
Configure your email template settings and content
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Email Groups */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">EMAIL GROUPS</Label>
<RadioGroup defaultValue="first" className="flex gap-6">
<div className="flex items-center space-x-2">
<RadioGroupItem value="first" id="first" className="border-slate-400 text-slate-400" />
<Label htmlFor="first" className="text-slate-600 font-medium">
FIRST
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="secondary" id="secondary" />
<Label htmlFor="secondary" className="text-gray-600">
SECONDARY
</Label>
</div>
</RadioGroup>
</div>
{/* Types of Emails */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">TYPES OF EMAILS</Label>
<Select defaultValue="consultation">
<SelectTrigger className="bg-slate-100 border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="consultation">(Person) Consultation Information</SelectItem>
</SelectContent>
</Select>
</div>
{/* Recipient */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">RECIPIENT (NUMBER OF RECIPIENTS)</Label>
<Input defaultValue="名人 (0人)" className="bg-white" readOnly />
</div>
{/* Source Address */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">SOURCE ADDRESS</Label>
<Input defaultValue="vm@1@localhost.local" className="bg-slate-100 border-slate-200" />
</div>
{/* Submission Content (Subject) */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">SUBMISSION CONTENT (SUBJECT)</Label>
<Input defaultValue="2021年度 健康診断受診のお知らせ" className="bg-slate-100 border-slate-200" />
</div>
{/* Contents of Transmission (Body) */}
<div>
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-medium text-gray-600">CONTENTS OF TRANSMISSION (BODY)</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setBodyExpanded(!bodyExpanded)}
className="text-slate-600 hover:text-slate-700"
>
expansion{" "}
{bodyExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
<Badge variant="secondary" className="text-xs">
{bodyText.length} / 1500
</Badge>
</div>
</div>
{bodyExpanded && (
<Textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
className="bg-slate-100 border-slate-200 min-h-[120px] resize-none"
maxLength={1500}
/>
)}
</div>
{/* Transmission Content (Footer) */}
<div>
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-medium text-gray-600">TRANSMISSION CONTENT (FOOTER)</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFooterExpanded(!footerExpanded)}
className="text-slate-600 hover:text-slate-700"
>
expansion{" "}
{footerExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</Button>
<Badge variant="secondary" className="text-xs">
{footerText.length} / 250
</Badge>
</div>
</div>
{footerExpanded && (
<div className="relative">
<Textarea
value={footerText}
onChange={(e) => setFooterText(e.target.value)}
className="bg-slate-100 border-slate-200 min-h-[100px] resize-none"
maxLength={250}
/>
<div className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs">
</div>
</div>
)}
</div>
{/* Attachment */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">ATTACHMENT</Label>
<div className="space-y-3">
{attachments.map((attachment, index) => (
<div key={index} className="flex items-center gap-3">
<Button variant="outline" size="sm" className="text-xs bg-transparent">
Choose File
</Button>
<span className="text-sm text-gray-500">No file chosen</span>
{attachment && (
<>
<Input value={attachment} className="flex-1 bg-gray-50" readOnly />
<Button
variant="outline"
size="sm"
className="text-slate-600 hover:text-slate-700 bg-transparent"
>
<Download className="w-4 h-4 mr-1" />
download
</Button>
<Button
variant="outline"
size="sm"
className="text-blue-600 hover:text-blue-700 bg-transparent"
>
<X className="w-4 h-4 mr-1" />
clear
</Button>
</>
)}
{!attachment && index >= 3 && (
<Button
variant="outline"
size="sm"
className="text-blue-600 hover:text-blue-700 ml-auto bg-transparent"
>
<X className="w-4 h-4 mr-1" />
clear
</Button>
)}
</div>
))}
<p className="text-xs text-gray-500 mt-2">* Attachments are xls, xlsx, doc, docx, pdf and csv only</p>
</div>
</div>
{/* Send Test Email Address */}
<div>
<Label className="text-sm font-medium text-gray-600 mb-3 block">SEND TEST EMAIL ADDRESS</Label>
<Input className="bg-slate-100 border-slate-200" />
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,294 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Save, Loader2, Shield } from "lucide-react";
import { toast } from "sonner";
import { userEditFormSchema, type UserEditForm } from "@/schemas/user.schema";
import axios from "axios";
import { Header } from "@/components/common/header";
export default function UserEditPage() {
const params = useParams();
const router = useRouter();
const userId = params.id as string;
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<UserEditForm>({
resolver: zodResolver(userEditFormSchema),
defaultValues: {
username: "",
email: "",
firstName: "",
lastName: "",
password: "",
confirmPassword: "",
},
});
useEffect(() => {
const fetchUser = async () => {
try {
setIsLoading(true);
const response = await axios.get(`/api/user/${userId}`);
const userData = response.data.data; // Access the nested data property
form.reset({
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
password: "",
confirmPassword: "",
});
} catch (error) {
console.error("Error fetching user:", error);
toast.error("Failed to load user data");
router.push("/modules/user");
} finally {
setIsLoading(false);
}
};
if (userId) {
fetchUser();
}
}, [userId, form, router]);
const onSubmit = async (data: UserEditForm) => {
setIsSubmitting(true);
try {
const updateData: {
username: string;
email: string;
firstName: string;
lastName: string;
password?: string;
} = {
username: data.username,
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
};
// Only include password if it's provided
if (data.password && data.password.length > 0) {
updateData.password = data.password;
}
const response = await axios.put(`/api/user/${userId}`, updateData);
if (response.status === 200) {
toast.success("User updated successfully!");
router.push("/modules/user");
}
} catch (error) {
console.error("Error updating user:", error);
if (axios.isAxiosError(error) && error.response?.data?.message) {
toast.error(error.response.data.message);
} else {
toast.error("Failed to update user. Please try again.");
}
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div>
<Header />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</div>
</div>
);
}
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "User Management", href: "/modules/user" },
{ title: "Edit User", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => router.push("/modules/user")}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Users
</Button>
<div>
<h1 className="text-2xl font-bold">Edit User</h1>
<p className="text-muted-foreground">Update user information</p>
</div>
</div>
{/* Form */}
<Card>
<CardHeader>
<CardTitle>User Information</CardTitle>
<CardDescription>
Update the user&apos;s details below. Leave password fields empty to keep current password.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter email address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>New Password (optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter new password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Confirm new password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => router.push("/modules/user")}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating User...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Update User
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
{/* Permissions Management */}
<Card>
<CardHeader>
<CardTitle>Permissions</CardTitle>
<CardDescription>
Manage user permissions and access levels
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
onClick={() => router.push(`/modules/user/${userId}/permission`)}
disabled={isSubmitting}
className="flex items-center gap-2"
>
<Shield className="h-4 w-4" />
Manage Permissions
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,241 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Header } from "@/components/common/header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { ArrowLeft, Save, Shield, User } from "lucide-react";
interface Permission {
id: number;
name: string;
description: string;
isActive: boolean;
}
interface UserPermission {
id: number;
userId: number;
permissionId: number;
permission?: Permission;
}
interface User {
id: number;
username: string;
email: string;
firstName: string;
lastName: string;
}
export default function UserPermissionPage() {
const params = useParams();
const router = useRouter();
const userId = parseInt(params.id as string);
const [user, setUser] = useState<User | null>(null);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// Fetch user data
const userResponse = await fetch(`/api/user/${userId}`);
if (!userResponse.ok) {
throw new Error("Failed to fetch user data");
}
const userData: User = await userResponse.json();
setUser(userData);
// Fetch all permissions
const permissionsResponse = await fetch("/api/permissions");
if (!permissionsResponse.ok) {
throw new Error("Failed to fetch permissions");
}
const permissionsData: Permission[] = await permissionsResponse.json();
setPermissions(permissionsData);
// Fetch user permissions
const userPermissionsResponse = await fetch(`/api/user/${userId}/permissions`);
if (!userPermissionsResponse.ok) {
throw new Error("Failed to fetch user permissions");
}
const userPermissionsData: UserPermission[] = await userPermissionsResponse.json();
// Set selected permissions
const selectedIds: Set<number> = new Set<number>(userPermissionsData.map((up: UserPermission) => up.permissionId as number));
setSelectedPermissions(selectedIds);
} catch (error: unknown) {
console.error("Error fetching data:", error);
toast.error("Failed to load user permissions");
} finally {
setLoading(false);
}
};
fetchData();
}, [userId]);
const handlePermissionToggle = (permissionId: number) => {
const newSelected = new Set(selectedPermissions);
if (newSelected.has(permissionId)) {
newSelected.delete(permissionId);
} else {
newSelected.add(permissionId);
}
setSelectedPermissions(newSelected);
};
const handleSave = async () => {
try {
setSaving(true);
const axios = (await import("axios")).default;
await axios.put<UserPermission[]>(`/api/user/${userId}/permissions`, {
permissionIds: Array.from(selectedPermissions),
});
toast.success("User permissions updated successfully!");
} catch (error: unknown) {
console.error("Error updating permissions:", error);
toast.error("Failed to update permissions");
} finally {
setSaving(false);
}
};
const handleGoBack = () => {
router.push(`/modules/user/${userId}`);
};
if (loading) {
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "User Management", href: "/modules/user" },
{ title: "Edit User", href: `/modules/user/${userId}` },
{ title: "Permissions", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
</div>
</div>
<div className="grid gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</div>
</div>
</div>
);
}
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "User Management", href: "/modules/user" },
{ title: "Edit User", href: `/modules/user/${userId}` },
{ title: "Permissions", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">User Permissions</h1>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" />
<span>
{user?.firstName} {user?.lastName} ({user?.username})
</span>
<Badge variant="outline">
{selectedPermissions.size} permission{selectedPermissions.size !== 1 ? 's' : ''} selected
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleGoBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to User
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
{/* Permissions Grid */}
<div className="grid gap-4">
{permissions.map((permission) => (
<Card key={permission.id} className="cursor-pointer hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Checkbox
id={`permission-${permission.id}`}
checked={selectedPermissions.has(permission.id)}
onCheckedChange={() => handlePermissionToggle(permission.id)}
/>
<div className="space-y-1">
<CardTitle className="text-base">
{permission.name.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')}
</CardTitle>
<CardDescription>
{permission.description}
</CardDescription>
</div>
</div>
<Badge variant={selectedPermissions.has(permission.id) ? "default" : "secondary"}>
{selectedPermissions.has(permission.id) ? "Granted" : "Not Granted"}
</Badge>
</div>
</CardHeader>
</Card>
))}
</div>
{permissions.length === 0 && (
<Card>
<CardContent className="text-center py-8">
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No permissions available</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { userFormSchema, type UserForm } from "@/schemas/user.schema";
import axios from "axios";
import { Header } from "@/components/common/header";
export default function UserAddPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<UserForm>({
resolver: zodResolver(userFormSchema),
defaultValues: {
username: "",
email: "",
firstName: "",
lastName: "",
password: "",
confirmPassword: "",
},
});
const onSubmit = async (data: UserForm) => {
setIsSubmitting(true);
try {
const response = await axios.post("/api/user", {
username: data.username,
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
password: data.password,
});
if (response.status === 201) {
toast.success("User created successfully!");
router.push("/modules/user");
}
} catch (error) {
console.error("Error creating user:", error);
if (axios.isAxiosError(error) && error.response?.data?.message) {
toast.error(error.response.data.message);
} else {
toast.error("Failed to create user. Please try again.");
}
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "User Management", href: "/modules/user" },
{ title: "Add User", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => router.push("/modules/user")}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Users
</Button>
<div>
<h1 className="text-2xl font-bold">Add New User</h1>
<p className="text-muted-foreground">Create a new user account</p>
</div>
</div>
{/* Form */}
<Card>
<CardHeader>
<CardTitle>User Information</CardTitle>
<CardDescription>
Enter the user&apos;s details below to create a new account.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter email address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Confirm password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => router.push("/modules/user")}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating User...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Create User
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Loader2, Users, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import { ServerDataTable } from "@/components/ui/server-data-table";
import { User, createUserColumns } from "@/components/users/user-columns";
import { Header } from "@/components/common/header";
import { SortingState } from "@tanstack/react-table";
interface PaginationInfo {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
interface UserResponse {
success: boolean;
data: User[];
pagination: PaginationInfo;
message?: string;
}
export default function UserPage() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [query, setQuery] = useState({
page: 1,
pageSize: 10,
search: "",
sortBy: "id",
sortOrder: "asc" as "asc" | "desc",
});
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
});
// Initialize search and sorting state to match query
const [searchValue, setSearchValue] = useState(query.search);
const [sorting, setSorting] = useState<SortingState>(
query.sortBy ? [{ id: query.sortBy, desc: query.sortOrder === "desc" }] : []
);
const fetchUsers = useCallback(async () => {
try {
setIsLoading(true);
const params = new URLSearchParams({
page: query.page.toString(),
pageSize: query.pageSize.toString(),
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
const response = await axios.get<UserResponse>(`/api/user?${params}`);
if (response.data.success) {
setUsers(response.data.data);
setPagination(response.data.pagination);
} else {
toast.error(response.data.message || "Failed to load users");
}
} catch (error) {
console.error("Error fetching users:", error);
toast.error("Failed to load users");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [query]);
// Fetch users when query changes
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleAddUser = useCallback(() => {
router.push("/modules/user/add");
}, [router]);
const handleEditUser = useCallback((userId: number) => {
router.push(`/modules/user/${userId}`);
}, [router]);
const handlePermissionsUser = useCallback((userId: number) => {
router.push(`/modules/user/${userId}/permission`);
}, [router]);
const handleDeleteUser = useCallback(async (userId: number) => {
// Find the user to get their name for confirmation
const userToDelete = users.find(u => u.id === userId);
const userName = userToDelete ? `${userToDelete.firstName} ${userToDelete.lastName}` : `User #${userId}`;
if (!window.confirm(`Are you sure you want to delete ${userName}? This action cannot be undone.`)) {
return;
}
try {
const response = await axios.delete(`/api/user/${userId}`);
if (response.data.success) {
toast.success(response.data.message || "User deleted successfully");
// Refresh the user list
await fetchUsers();
} else {
toast.error(response.data.message || "Failed to delete user");
}
} catch (error) {
console.error("Error deleting user:", error);
toast.error("Failed to delete user");
}
}, [fetchUsers, users]);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await fetchUsers();
toast.success("User list refreshed");
}, [fetchUsers]);
const handlePaginationChange = useCallback((page: number, pageSize: number) => {
setQuery((prev) => ({ ...prev, page, pageSize }));
}, []);
// Search handler - will be called from ServerDataTable after debounce
const handleSearchChange = useCallback((search: string) => {
setQuery((prev) => ({ ...prev, search, page: 1 }));
}, []);
// Sorting handler - will be called from ServerDataTable
const handleSortingChange = useCallback((sortByField: string, sortOrderValue: "asc" | "desc") => {
setQuery((prev) => ({ ...prev, sortBy: sortByField, sortOrder: sortOrderValue }));
setSorting([{ id: sortByField, desc: sortOrderValue === "desc" }]);
}, []);
const columns = useMemo(() => createUserColumns({
onEdit: handleEditUser,
onDelete: handleDeleteUser,
onPermissions: handlePermissionsUser,
}), [handleEditUser, handleDeleteUser, handlePermissionsUser]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span>Loading users...</span>
</div>
</div>
);
}
return (
<div>
<Header
breadcrumbs={[
{ title: "Home", href: "/" },
{ title: "User Management", isCurrentPage: true }
]}
/>
<div className="container mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Users className="h-8 w-8" />
<h1 className="text-3xl font-bold">User Management</h1>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={handleRefresh}
variant="outline"
size="sm"
disabled={isRefreshing}
className="flex items-center space-x-2"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
<span>Refresh</span>
</Button>
<Button onClick={handleAddUser} className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add User</span>
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>
Manage your user database ({pagination.total} total)
</CardDescription>
</CardHeader>
<CardContent>
{pagination.total > 0 ? (
<ServerDataTable
columns={columns}
data={users}
pagination={pagination}
searchKey="name"
searchPlaceholder="Search users by name..."
isLoading={isLoading}
onPaginationChange={handlePaginationChange}
onSearchChange={handleSearchChange}
onSortingChange={handleSortingChange}
searchValue={searchValue}
sorting={sorting}
setSearchValue={setSearchValue}
setSorting={setSorting}
/>
) : (
<div className="text-center py-8 text-gray-500">
<Users className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium">No users found</p>
<p className="text-sm">Get started by adding your first user</p>
<Button onClick={handleAddUser} className="mt-4">
<Plus className="h-4 w-4 mr-2" />
Add User
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@radix-ui/react-select";
interface BreadcrumbItemData {
title: string;
href?: string;
isCurrentPage?: boolean;
}
interface HeaderProps {
title?: string;
parentTitle?: string;
parentHref?: string;
showParent?: boolean;
breadcrumbs?: BreadcrumbItemData[];
}
export function Header({
title = "Data Fetching",
parentTitle = "Building Your Application",
parentHref = "#",
showParent = true,
breadcrumbs
}: HeaderProps) {
// Use breadcrumbs prop if provided, otherwise fall back to legacy props
const breadcrumbItems = breadcrumbs || [
...(showParent ? [{ title: parentTitle, href: parentHref }] : []),
{ title, isCurrentPage: true }
];
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
{breadcrumbItems.map((item, index) => (
<div key={index} className="flex items-center">
<BreadcrumbItem className={index === 0 ? "hidden md:block" : ""}>
{item.isCurrentPage ? (
<BreadcrumbPage>{item.title}</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href || "#"}>
{item.title}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < breadcrumbItems.length - 1 && (
<BreadcrumbSeparator className="hidden md:block" />
)}
</div>
))}
</BreadcrumbList>
</Breadcrumb>
</header>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { ArrowUpDown, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
export interface Customer {
id: number;
firstNameEn: string;
lastNameEn: string;
name: string;
email: string;
mobile: string;
originAdd1: string;
localAdd1: string;
dependants?: Array<{
id: number;
custId: number;
firstNameEn: string;
lastNameEn: string;
email: string;
mobile: string;
originAdd1: string;
localAdd1: string;
}>;
}
interface CustomerColumnsProps {
onEdit: (customerId: number) => void;
onDelete: (customerId: number) => void;
}
export const createCustomerColumns = ({
onEdit,
onDelete,
}: CustomerColumnsProps): ColumnDef<Customer>[] => [
{
accessorKey: "id",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 px-2 lg:px-3"
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div className="font-medium">{row.getValue("id")}</div>;
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 px-2 lg:px-3"
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const customer = row.original;
return (
<div
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer font-medium"
onClick={() => onEdit(customer.id)}
>
{customer.firstNameEn} {customer.lastNameEn}
</div>
);
},
filterFn: (row, id, value) => {
const customer = row.original;
const fullName = `${customer.firstNameEn} ${customer.lastNameEn}`.toLowerCase();
return fullName.includes(value.toLowerCase());
},
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 px-2 lg:px-3"
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div className="lowercase">{row.getValue("email")}</div>;
},
},
{
accessorKey: "mobile",
header: "Mobile",
cell: ({ row }) => {
return <div className="font-mono">{row.getValue("mobile")}</div>;
},
},
{
id: "dependants",
header: "Dependants",
cell: ({ row }) => {
const customer = row.original;
const dependantCount = customer.dependants?.length || 0;
return (
<div className="flex items-center">
{dependantCount > 0 ? (
<Badge variant="secondary" className="text-xs">
{dependantCount} dependant{dependantCount > 1 ? "s" : ""}
</Badge>
) : (
<span className="text-muted-foreground text-sm">None</span>
)}
</div>
);
},
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const customer = row.original;
return (
<Button
variant="outline"
size="sm"
onClick={() => onDelete(customer.id)}
className="text-red-600 hover:text-red-800 hover:bg-red-50"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
);
},
},
];

View File

@@ -0,0 +1,73 @@
'use client'
import * as React from "react"
import { GalleryVerticalEnd } from "lucide-react"
import { NavMain } from "@/components/sidebar/nav-main"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
// This is sample data.
const data = {
navMain: [
{
title: "Customer",
url: "/modules/customer",
},
{
title: "User",
url: "/modules/user",
},
{
title: "Mail Template",
url: "/modules/mail-template",
},
],
}
import { usePathname } from "next/navigation"
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const navItems = data.navMain.map((item) => ({
...item,
isActive: pathname.startsWith(item.url),
}))
return (
<Sidebar {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<GalleryVerticalEnd className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">Cosmos Plan Prototype</span>
<span className="">v1.0.0</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
<div className="p-1">
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import { type LucideIcon } from "lucide-react"
import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
}[]
}) {
return (
<SidebarGroup>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild className={item.isActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : undefined}>
<a href={item.url}>
{item.title}
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,210 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,179 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
ColumnFiltersState,
getFilteredRowModel,
} from "@tanstack/react-table";
import { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
searchPlaceholder?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchPlaceholder = "Search...",
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: {
sorting,
columnFilters,
},
});
return (
<div className="space-y-4">
{/* Search Input */}
{searchKey && (
<div className="flex items-center py-4">
<Input
placeholder={searchPlaceholder}
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn(searchKey)?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
)}
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="font-medium">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} of{" "}
{data.length} total entries
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

167
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none 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
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,253 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useEffect, useRef, useCallback } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface PaginationInfo {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
interface ServerDataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination: PaginationInfo;
searchKey?: string;
searchPlaceholder?: string;
isLoading?: boolean;
onPaginationChange: (page: number, pageSize: number) => void;
onSearchChange: (search: string) => void;
onSortingChange: (sortBy: string, sortOrder: "asc" | "desc") => void;
searchValue: string;
sorting: SortingState;
setSearchValue: (value: string) => void;
setSorting: (value: SortingState) => void;
}
export function ServerDataTable<TData, TValue>({
columns,
data,
pagination,
searchKey,
searchPlaceholder = "Search...",
isLoading = false,
onPaginationChange,
onSearchChange,
onSortingChange,
searchValue,
sorting,
setSearchValue,
setSorting,
}: ServerDataTableProps<TData, TValue>) {
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: (updater) => {
// updater can be a function or value
const nextSorting = typeof updater === "function" ? updater(sorting) : updater;
setSorting(nextSorting);
if (nextSorting.length > 0) {
const sort = nextSorting[0];
onSortingChange(sort.id, sort.desc ? "desc" : "asc");
}
},
state: {
sorting,
},
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: pagination.totalPages,
});
// Handle search input change with debounce
const handleSearchInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setSearchValue(value);
// Clear existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// Set new timeout for API call
searchTimeoutRef.current = setTimeout(() => {
onSearchChange(value);
}, 300);
}, [setSearchValue, onSearchChange]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
const handlePageSizeChange = (newPageSize: number) => {
onPaginationChange(1, newPageSize);
};
const handlePageChange = (newPage: number) => {
onPaginationChange(newPage, pagination.pageSize);
};
return (
<div className="space-y-4">
{/* Search Input */}
{searchKey && (
<div className="flex items-center py-4">
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchInputChange}
className="max-w-sm"
disabled={isLoading}
/>
</div>
)}
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="font-medium">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
{pagination.total > 0 ? (
<>
Showing {(pagination.page - 1) * pagination.pageSize + 1} to{" "}
{Math.min(pagination.page * pagination.pageSize, pagination.total)} of{" "}
{pagination.total} entries
</>
) : (
"No entries found"
)}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${pagination.pageSize}`}
onValueChange={(value) => handlePageSizeChange(Number(value))}
disabled={isLoading}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {pagination.page} of {pagination.totalPages}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageChange(1)}
disabled={!pagination.hasPreviousPage || isLoading}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(pagination.page - 1)}
disabled={!pagination.hasPreviousPage || isLoading}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(pagination.page + 1)}
disabled={!pagination.hasNextPage || isLoading}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageChange(pagination.totalPages)}
disabled={!pagination.hasNextPage || isLoading}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}

139
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,113 @@
import { Button } from "@/components/ui/button";
import { Key, ArrowUpDown } from "lucide-react";
import { Column } from "@tanstack/react-table";
export interface User {
id: number;
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
isDeleted: boolean;
}
export function createUserColumns({ onEdit, onDelete, onPermissions }: {
onEdit?: (userId: number) => void;
onDelete?: (userId: number) => void;
onPermissions?: (userId: number) => void;
}) {
return [
{
accessorKey: "id",
header: ({ column }: { column: Column<User, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 p-0 font-medium"
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
enableSorting: true,
},
{
accessorKey: "fullName",
header: ({ column }: { column: Column<User, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 p-0 font-medium"
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: (row: { row: { original: User } }) => (
<button
onClick={() => onEdit?.(row.row.original.id)}
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer font-medium"
>
{`${row.row.original.firstName} ${row.row.original.lastName}`}
</button>
),
enableSorting: true,
},
{
accessorKey: "email",
header: ({ column }: { column: Column<User, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 p-0 font-medium"
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
enableSorting: true,
},
{
accessorKey: "username",
header: ({ column }: { column: Column<User, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-8 p-0 font-medium"
>
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
enableSorting: true,
},
{
id: "actions",
header: "Actions",
cell: (row: { row: { original: User } }) => (
<div className="flex gap-2">
{onPermissions && (
<Button size="sm" variant="outline" onClick={() => onPermissions(row.row.original.id)}>
<Key className="h-4 w-4 mr-1" />
Permissions
</Button>
)}
{onDelete && (
<Button size="sm" variant="destructive" onClick={() => onDelete(row.row.original.id)}>
Delete
</Button>
)}
</div>
),
},
];
}

View File

@@ -0,0 +1,116 @@
// database.schema.ts
export interface Customer {
id: number;
firstNameEn: string;
lastNameEn: string;
// firstNameJp: string;
// lastNameJp: string;
// firstNameJpKana: string;
// lastNameJpKana: string;
email: string;
// email2?: string;
mobile: string;
originAdd1: string;
// originAdd2: string;
// originAdd3: string;
// originCity: string;
// originState: string;
// originPostcode: string;
// originCountry: string;
localAdd1: string;
// localAdd2: string;
// localAdd3: string;
// localCity: string;
// localState: string;
// localPostcode: string;
// localCountry: string;
// preferLang: string;
// dob: string; // ISO string
// gender: string;
// martial: string;
// occupation: string;
// ecName: string;
// ecRelation: string;
// ecEmail: string;
// ecMobile: string;
// initialOutReach?: string;
// initialOutReachRemark?: string;
// consentGiven?: string;
// contentDate?: string;
// status: string;
// remarks: string;
// potcustId?: string;
// isDeleted: boolean;
}
export interface User {
id: number;
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
isDeleted: boolean;
}
export interface UserPermission {
id: number;
userId: number;
permissionId: number;
}
export interface Permission {
id: number;
name: string;
description: string;
isActive: boolean;
}
export interface CustomerDependant {
id: number;
custId: number;
// deptSeq: number;
firstNameEn: string;
lastNameEn: string;
// firstNameJp: string;
// lastNameJp: string;
// firstNameJpKana: string;
// lastNameJpKana: string;
email: string;
// email2?: string;
mobile: string;
originAdd1: string;
// originAdd2: string;
// originAdd3: string;
// originCity: string;
// originState: string;
// originPostcode: string;
// originCountry: string;
localAdd1: string;
// localAdd2: string;
// localAdd3: string;
// localCity: string;
// localState: string;
// localPostcode: string;
// localCountry: string;
// preferLang: string;
// dob: string;
// gender: string;
// martial: string;
// occupation: string;
// ecName: string;
// ecRelation: string;
// ecEmail: string;
// ecMobile: string;
// isDeleted: boolean;
}
// Lowdb root schema
export interface DBSchema {
customers: Customer[];
users: User[];
userPermissions: UserPermission[];
permissions: Permission[];
customerDependants: CustomerDependant[];
}

93
src/database/database.ts Normal file
View File

@@ -0,0 +1,93 @@
import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
import { DBSchema } from "./database.schema";
import { defaultAdminUser } from "./defaultAdminUser";
import { sampleUsers } from "./sampleUsers";
import { samplePermissions } from "./samplePermissions";
import { sampleUserPermissions } from "./sampleUserPermissions";
import { sampleCustomers } from "./sampleCustomers";
import { sampleCustomerDependants } from "./sampleCustomerDependants";
import path from "path";
// File path for db.json
const file = path.resolve(process.cwd(), "src/database/db.json");
const adapter = new JSONFile<DBSchema>(file);
const db = new Low<DBSchema>(adapter, {
customers: [],
users: [],
userPermissions: [],
permissions: [],
customerDependants: [],
});
// Helper to generate next id for a collection
export function getNextId<T extends { id: number }>(items: T[]): number {
if (!items || items.length === 0) return 1;
return Math.max(...items.map((item) => item.id)) + 1;
}
// Initialize DB and ensure sample data exists
export async function initDB() {
await db.read();
db.data ||= { customers: [], users: [], customerDependants: [], userPermissions: [], permissions: [] };
// Initialize customers with sample data
if (db.data.customers.length === 0) {
db.data.customers.push(...sampleCustomers);
}
// Initialize customer dependants with sample data
if (db.data.customerDependants.length === 0) {
db.data.customerDependants.push(...sampleCustomerDependants);
}
// Initialize users with default admin and sample users
if (db.data.users.length === 0) {
db.data.users.push(defaultAdminUser);
db.data.users.push(...sampleUsers);
}
// Initialize permissions with sample data
if (db.data.permissions.length === 0) {
db.data.permissions.push(...samplePermissions);
}
// Initialize user permissions with sample data
if (db.data.userPermissions.length === 0) {
db.data.userPermissions.push(...sampleUserPermissions);
}
await db.write();
}
// Reset database by clearing all data and reinitializing with sample data
export async function resetDB() {
// Clear all existing data
db.data = {
customers: [],
users: [],
customerDependants: [],
userPermissions: [],
permissions: [],
};
// Initialize customers with sample data
db.data.customers.push(...sampleCustomers);
// Initialize customer dependants with sample data
db.data.customerDependants.push(...sampleCustomerDependants);
// Initialize users with default admin and sample users
db.data.users.push(defaultAdminUser);
db.data.users.push(...sampleUsers);
// Initialize permissions with sample data
db.data.permissions.push(...samplePermissions);
// Initialize user permissions with sample data
db.data.userPermissions.push(...sampleUserPermissions);
await db.write();
}
export { db };

626
src/database/db.json Normal file
View File

@@ -0,0 +1,626 @@
{
"customers": [
{
"id": 1,
"firstNameEn": "John",
"lastNameEn": "Smith",
"email": "john.smith@email.com",
"mobile": "+1-555-0101",
"originAdd1": "123 Main Street, New York, NY 10001, USA",
"localAdd1": "456 Oak Avenue, Tokyo, Japan 150-0001"
},
{
"id": 2,
"firstNameEn": "Sarah",
"lastNameEn": "Johnson",
"email": "sarah.johnson@email.com",
"mobile": "+1-555-0102",
"originAdd1": "789 Pine Road, Los Angeles, CA 90210, USA",
"localAdd1": "321 Sakura Street, Osaka, Japan 530-0001"
},
{
"id": 3,
"firstNameEn": "Michael",
"lastNameEn": "Brown",
"email": "michael.brown@email.com",
"mobile": "+44-20-7946-0958",
"originAdd1": "42 Victoria Street, London, UK SW1H 0TL",
"localAdd1": "789 Shibuya Crossing, Tokyo, Japan 150-0002"
},
{
"id": 4,
"firstNameEn": "Emily",
"lastNameEn": "Davis",
"email": "emily.davis@email.com",
"mobile": "+61-2-9876-5432",
"originAdd1": "15 Collins Street, Melbourne, VIC 3000, Australia",
"localAdd1": "654 Namba District, Osaka, Japan 542-0076"
},
{
"id": 5,
"firstNameEn": "David",
"lastNameEn": "Wilson",
"email": "david.wilson@email.com",
"mobile": "+1-416-555-0199",
"originAdd1": "100 Queen Street West, Toronto, ON M5H 2N2, Canada",
"localAdd1": "987 Ginza District, Tokyo, Japan 104-0061"
}
],
"users": [
{
"id": 1,
"username": "admin",
"email": "admin@example.com",
"firstName": "Admin",
"lastName": "User",
"password": "admin123",
"isDeleted": false
},
{
"id": 2,
"username": "john.doe",
"email": "john.doe@example.com",
"firstName": "John",
"lastName": "Doe",
"password": "password123",
"isDeleted": false
},
{
"id": 3,
"username": "jane.smith",
"email": "jane.smith@example.com",
"firstName": "Jane",
"lastName": "Smith",
"password": "password123",
"isDeleted": false
},
{
"id": 4,
"username": "mike.johnson",
"email": "mike.johnson@example.com",
"firstName": "Mike",
"lastName": "Johnson",
"password": "password123",
"isDeleted": false
},
{
"id": 5,
"username": "sarah.wilson",
"email": "sarah.wilson@example.com",
"firstName": "Sarah",
"lastName": "Wilson",
"password": "password123",
"isDeleted": false
},
{
"id": 6,
"username": "david.brown",
"email": "david.brown@example.com",
"firstName": "David",
"lastName": "Brown",
"password": "password123",
"isDeleted": false
},
{
"id": 7,
"username": "emma.davis",
"email": "emma.davis@example.com",
"firstName": "Emma",
"lastName": "Davis",
"password": "password123",
"isDeleted": false
},
{
"id": 8,
"username": "alex.martinez",
"email": "alex.martinez@example.com",
"firstName": "Alex",
"lastName": "Martinez",
"password": "password123",
"isDeleted": false
},
{
"id": 9,
"username": "lisa.garcia",
"email": "lisa.garcia@example.com",
"firstName": "Lisa",
"lastName": "Garcia",
"password": "password123",
"isDeleted": false
},
{
"id": 10,
"username": "robert.taylor",
"email": "robert.taylor@example.com",
"firstName": "Robert",
"lastName": "Taylor",
"password": "password123",
"isDeleted": false
},
{
"id": 11,
"username": "maria.rodriguez",
"email": "maria.rodriguez@example.com",
"firstName": "Maria",
"lastName": "Rodriguez",
"password": "password123",
"isDeleted": false
}
],
"userPermissions": [
{
"id": 1,
"userId": 1,
"permissionId": 1
},
{
"id": 2,
"userId": 1,
"permissionId": 2
},
{
"id": 3,
"userId": 1,
"permissionId": 3
},
{
"id": 4,
"userId": 1,
"permissionId": 4
},
{
"id": 5,
"userId": 1,
"permissionId": 5
},
{
"id": 6,
"userId": 1,
"permissionId": 6
},
{
"id": 7,
"userId": 1,
"permissionId": 7
},
{
"id": 8,
"userId": 1,
"permissionId": 8
},
{
"id": 9,
"userId": 1,
"permissionId": 9
},
{
"id": 10,
"userId": 1,
"permissionId": 10
},
{
"id": 11,
"userId": 1,
"permissionId": 11
},
{
"id": 12,
"userId": 1,
"permissionId": 12
},
{
"id": 13,
"userId": 1,
"permissionId": 13
},
{
"id": 14,
"userId": 1,
"permissionId": 14
},
{
"id": 15,
"userId": 1,
"permissionId": 15
},
{
"id": 20,
"userId": 3,
"permissionId": 1
},
{
"id": 21,
"userId": 3,
"permissionId": 2
},
{
"id": 22,
"userId": 3,
"permissionId": 14
},
{
"id": 23,
"userId": 3,
"permissionId": 8
},
{
"id": 24,
"userId": 4,
"permissionId": 4
},
{
"id": 25,
"userId": 4,
"permissionId": 1
},
{
"id": 26,
"userId": 4,
"permissionId": 8
},
{
"id": 27,
"userId": 4,
"permissionId": 9
},
{
"id": 28,
"userId": 4,
"permissionId": 15
},
{
"id": 29,
"userId": 5,
"permissionId": 4
},
{
"id": 30,
"userId": 5,
"permissionId": 5
},
{
"id": 31,
"userId": 5,
"permissionId": 11
},
{
"id": 32,
"userId": 5,
"permissionId": 12
},
{
"id": 33,
"userId": 6,
"permissionId": 7
},
{
"id": 34,
"userId": 6,
"permissionId": 10
},
{
"id": 35,
"userId": 6,
"permissionId": 15
},
{
"id": 36,
"userId": 6,
"permissionId": 1
},
{
"id": 37,
"userId": 6,
"permissionId": 2
},
{
"id": 38,
"userId": 7,
"permissionId": 11
},
{
"id": 39,
"userId": 7,
"permissionId": 12
},
{
"id": 40,
"userId": 7,
"permissionId": 13
},
{
"id": 41,
"userId": 7,
"permissionId": 4
},
{
"id": 42,
"userId": 8,
"permissionId": 4
},
{
"id": 43,
"userId": 8,
"permissionId": 1
},
{
"id": 44,
"userId": 8,
"permissionId": 11
},
{
"id": 45,
"userId": 9,
"permissionId": 4
},
{
"id": 46,
"userId": 9,
"permissionId": 5
},
{
"id": 47,
"userId": 9,
"permissionId": 6
},
{
"id": 48,
"userId": 9,
"permissionId": 8
},
{
"id": 49,
"userId": 9,
"permissionId": 11
},
{
"id": 50,
"userId": 10,
"permissionId": 4
},
{
"id": 51,
"userId": 10,
"permissionId": 1
},
{
"id": 52,
"userId": 10,
"permissionId": 8
},
{
"id": 53,
"userId": 10,
"permissionId": 9
},
{
"id": 54,
"userId": 11,
"permissionId": 4
},
{
"id": 55,
"userId": 11,
"permissionId": 5
},
{
"id": 56,
"userId": 11,
"permissionId": 8
},
{
"id": 57,
"userId": 11,
"permissionId": 11
},
{
"id": 58,
"userId": 11,
"permissionId": 12
},
{
"id": 59,
"userId": 2,
"permissionId": 4
},
{
"id": 60,
"userId": 2,
"permissionId": 5
},
{
"id": 61,
"userId": 2,
"permissionId": 8
},
{
"id": 62,
"userId": 2,
"permissionId": 11
}
],
"permissions": [
{
"id": 1,
"name": "user_read",
"description": "Read user information",
"isActive": true
},
{
"id": 2,
"name": "user_write",
"description": "Create and update user information",
"isActive": true
},
{
"id": 3,
"name": "user_delete",
"description": "Delete user accounts",
"isActive": true
},
{
"id": 4,
"name": "customer_read",
"description": "Read customer information",
"isActive": true
},
{
"id": 5,
"name": "customer_write",
"description": "Create and update customer information",
"isActive": true
},
{
"id": 6,
"name": "customer_delete",
"description": "Delete customer records",
"isActive": true
},
{
"id": 7,
"name": "admin_panel",
"description": "Access administrative panel",
"isActive": true
},
{
"id": 8,
"name": "reports_view",
"description": "View system reports",
"isActive": true
},
{
"id": 9,
"name": "reports_export",
"description": "Export reports and data",
"isActive": true
},
{
"id": 10,
"name": "system_settings",
"description": "Modify system configuration",
"isActive": true
},
{
"id": 11,
"name": "mail_template_read",
"description": "Read mail templates",
"isActive": true
},
{
"id": 12,
"name": "mail_template_write",
"description": "Create and update mail templates",
"isActive": true
},
{
"id": 13,
"name": "mail_template_delete",
"description": "Delete mail templates",
"isActive": true
},
{
"id": 14,
"name": "permission_manage",
"description": "Manage user permissions",
"isActive": true
},
{
"id": 15,
"name": "audit_log",
"description": "View audit logs and system activities",
"isActive": true
}
],
"customerDependants": [
{
"id": 1,
"custId": 1,
"firstNameEn": "Jane",
"lastNameEn": "Smith",
"email": "jane.smith@email.com",
"mobile": "+1-555-0111",
"originAdd1": "123 Main Street, New York, NY 10001, USA",
"localAdd1": "456 Oak Avenue, Tokyo, Japan 150-0001"
},
{
"id": 2,
"custId": 1,
"firstNameEn": "Tommy",
"lastNameEn": "Smith",
"email": "tommy.smith@email.com",
"mobile": "+1-555-0112",
"originAdd1": "123 Main Street, New York, NY 10001, USA",
"localAdd1": "456 Oak Avenue, Tokyo, Japan 150-0001"
},
{
"id": 3,
"custId": 2,
"firstNameEn": "Mark",
"lastNameEn": "Johnson",
"email": "mark.johnson@email.com",
"mobile": "+1-555-0121",
"originAdd1": "789 Pine Road, Los Angeles, CA 90210, USA",
"localAdd1": "321 Sakura Street, Osaka, Japan 530-0001"
},
{
"id": 4,
"custId": 2,
"firstNameEn": "Lisa",
"lastNameEn": "Johnson",
"email": "lisa.johnson@email.com",
"mobile": "+1-555-0122",
"originAdd1": "789 Pine Road, Los Angeles, CA 90210, USA",
"localAdd1": "321 Sakura Street, Osaka, Japan 530-0001"
},
{
"id": 5,
"custId": 3,
"firstNameEn": "Anna",
"lastNameEn": "Brown",
"email": "anna.brown@email.com",
"mobile": "+44-20-7946-0959",
"originAdd1": "42 Victoria Street, London, UK SW1H 0TL",
"localAdd1": "789 Shibuya Crossing, Tokyo, Japan 150-0002"
},
{
"id": 6,
"custId": 4,
"firstNameEn": "James",
"lastNameEn": "Davis",
"email": "james.davis@email.com",
"mobile": "+61-2-9876-5433",
"originAdd1": "15 Collins Street, Melbourne, VIC 3000, Australia",
"localAdd1": "654 Namba District, Osaka, Japan 542-0076"
},
{
"id": 7,
"custId": 4,
"firstNameEn": "Sophie",
"lastNameEn": "Davis",
"email": "sophie.davis@email.com",
"mobile": "+61-2-9876-5434",
"originAdd1": "15 Collins Street, Melbourne, VIC 3000, Australia",
"localAdd1": "654 Namba District, Osaka, Japan 542-0076"
},
{
"id": 8,
"custId": 4,
"firstNameEn": "Oliver",
"lastNameEn": "Davis",
"email": "oliver.davis@email.com",
"mobile": "+61-2-9876-5435",
"originAdd1": "15 Collins Street, Melbourne, VIC 3000, Australia",
"localAdd1": "654 Namba District, Osaka, Japan 542-0076"
},
{
"id": 9,
"custId": 5,
"firstNameEn": "Rachel",
"lastNameEn": "Wilson",
"email": "rachel.wilson@email.com",
"mobile": "+1-416-555-0191",
"originAdd1": "100 Queen Street West, Toronto, ON M5H 2N2, Canada",
"localAdd1": "987 Ginza District, Tokyo, Japan 104-0061"
}
]
}

View File

@@ -0,0 +1,11 @@
import { User } from "./database.schema";
export const defaultAdminUser: User = {
id: 1,
username: "admin",
email: "admin@example.com",
firstName: "Admin",
lastName: "User",
password: "admin123", // You should hash this in production
isDeleted: false,
};

View File

@@ -0,0 +1,99 @@
import { CustomerDependant } from "./database.schema";
export const sampleCustomerDependants: CustomerDependant[] = [
// Dependants for John Smith (custId: 1)
{
id: 1,
custId: 1,
firstNameEn: "Jane",
lastNameEn: "Smith",
email: "jane.smith@email.com",
mobile: "+1-555-0111",
originAdd1: "123 Main Street, New York, NY 10001, USA",
localAdd1: "456 Oak Avenue, Tokyo, Japan 150-0001",
},
{
id: 2,
custId: 1,
firstNameEn: "Tommy",
lastNameEn: "Smith",
email: "tommy.smith@email.com",
mobile: "+1-555-0112",
originAdd1: "123 Main Street, New York, NY 10001, USA",
localAdd1: "456 Oak Avenue, Tokyo, Japan 150-0001",
},
// Dependants for Sarah Johnson (custId: 2)
{
id: 3,
custId: 2,
firstNameEn: "Mark",
lastNameEn: "Johnson",
email: "mark.johnson@email.com",
mobile: "+1-555-0121",
originAdd1: "789 Pine Road, Los Angeles, CA 90210, USA",
localAdd1: "321 Sakura Street, Osaka, Japan 530-0001",
},
{
id: 4,
custId: 2,
firstNameEn: "Lisa",
lastNameEn: "Johnson",
email: "lisa.johnson@email.com",
mobile: "+1-555-0122",
originAdd1: "789 Pine Road, Los Angeles, CA 90210, USA",
localAdd1: "321 Sakura Street, Osaka, Japan 530-0001",
},
// Dependants for Michael Brown (custId: 3)
{
id: 5,
custId: 3,
firstNameEn: "Anna",
lastNameEn: "Brown",
email: "anna.brown@email.com",
mobile: "+44-20-7946-0959",
originAdd1: "42 Victoria Street, London, UK SW1H 0TL",
localAdd1: "789 Shibuya Crossing, Tokyo, Japan 150-0002",
},
// Dependants for Emily Davis (custId: 4)
{
id: 6,
custId: 4,
firstNameEn: "James",
lastNameEn: "Davis",
email: "james.davis@email.com",
mobile: "+61-2-9876-5433",
originAdd1: "15 Collins Street, Melbourne, VIC 3000, Australia",
localAdd1: "654 Namba District, Osaka, Japan 542-0076",
},
{
id: 7,
custId: 4,
firstNameEn: "Sophie",
lastNameEn: "Davis",
email: "sophie.davis@email.com",
mobile: "+61-2-9876-5434",
originAdd1: "15 Collins Street, Melbourne, VIC 3000, Australia",
localAdd1: "654 Namba District, Osaka, Japan 542-0076",
},
{
id: 8,
custId: 4,
firstNameEn: "Oliver",
lastNameEn: "Davis",
email: "oliver.davis@email.com",
mobile: "+61-2-9876-5435",
originAdd1: "15 Collins Street, Melbourne, VIC 3000, Australia",
localAdd1: "654 Namba District, Osaka, Japan 542-0076",
},
// Dependants for David Wilson (custId: 5)
{
id: 9,
custId: 5,
firstNameEn: "Rachel",
lastNameEn: "Wilson",
email: "rachel.wilson@email.com",
mobile: "+1-416-555-0191",
originAdd1: "100 Queen Street West, Toronto, ON M5H 2N2, Canada",
localAdd1: "987 Ginza District, Tokyo, Japan 104-0061",
},
];

View File

@@ -0,0 +1,49 @@
import { Customer } from "./database.schema";
export const sampleCustomers: Customer[] = [
{
id: 1,
firstNameEn: "John",
lastNameEn: "Smith",
email: "john.smith@email.com",
mobile: "+1-555-0101",
originAdd1: "123 Main Street, New York, NY 10001, USA",
localAdd1: "456 Oak Avenue, Tokyo, Japan 150-0001",
},
{
id: 2,
firstNameEn: "Sarah",
lastNameEn: "Johnson",
email: "sarah.johnson@email.com",
mobile: "+1-555-0102",
originAdd1: "789 Pine Road, Los Angeles, CA 90210, USA",
localAdd1: "321 Sakura Street, Osaka, Japan 530-0001",
},
{
id: 3,
firstNameEn: "Michael",
lastNameEn: "Brown",
email: "michael.brown@email.com",
mobile: "+44-20-7946-0958",
originAdd1: "42 Victoria Street, London, UK SW1H 0TL",
localAdd1: "789 Shibuya Crossing, Tokyo, Japan 150-0002",
},
{
id: 4,
firstNameEn: "Emily",
lastNameEn: "Davis",
email: "emily.davis@email.com",
mobile: "+61-2-9876-5432",
originAdd1: "15 Collins Street, Melbourne, VIC 3000, Australia",
localAdd1: "654 Namba District, Osaka, Japan 542-0076",
},
{
id: 5,
firstNameEn: "David",
lastNameEn: "Wilson",
email: "david.wilson@email.com",
mobile: "+1-416-555-0199",
originAdd1: "100 Queen Street West, Toronto, ON M5H 2N2, Canada",
localAdd1: "987 Ginza District, Tokyo, Japan 104-0061",
},
];

View File

@@ -0,0 +1,94 @@
import { Permission } from "./database.schema";
export const samplePermissions: Permission[] = [
{
id: 1,
name: "user_read",
description: "Read user information",
isActive: true,
},
{
id: 2,
name: "user_write",
description: "Create and update user information",
isActive: true,
},
{
id: 3,
name: "user_delete",
description: "Delete user accounts",
isActive: true,
},
{
id: 4,
name: "customer_read",
description: "Read customer information",
isActive: true,
},
{
id: 5,
name: "customer_write",
description: "Create and update customer information",
isActive: true,
},
{
id: 6,
name: "customer_delete",
description: "Delete customer records",
isActive: true,
},
{
id: 7,
name: "admin_panel",
description: "Access administrative panel",
isActive: true,
},
{
id: 8,
name: "reports_view",
description: "View system reports",
isActive: true,
},
{
id: 9,
name: "reports_export",
description: "Export reports and data",
isActive: true,
},
{
id: 10,
name: "system_settings",
description: "Modify system configuration",
isActive: true,
},
{
id: 11,
name: "mail_template_read",
description: "Read mail templates",
isActive: true,
},
{
id: 12,
name: "mail_template_write",
description: "Create and update mail templates",
isActive: true,
},
{
id: 13,
name: "mail_template_delete",
description: "Delete mail templates",
isActive: true,
},
{
id: 14,
name: "permission_manage",
description: "Manage user permissions",
isActive: true,
},
{
id: 15,
name: "audit_log",
description: "View audit logs and system activities",
isActive: true,
},
];

View File

@@ -0,0 +1,83 @@
import { UserPermission } from "./database.schema";
export const sampleUserPermissions: UserPermission[] = [
// Admin user (id: 1) gets all permissions
{ id: 1, userId: 1, permissionId: 1 },
{ id: 2, userId: 1, permissionId: 2 },
{ id: 3, userId: 1, permissionId: 3 },
{ id: 4, userId: 1, permissionId: 4 },
{ id: 5, userId: 1, permissionId: 5 },
{ id: 6, userId: 1, permissionId: 6 },
{ id: 7, userId: 1, permissionId: 7 },
{ id: 8, userId: 1, permissionId: 8 },
{ id: 9, userId: 1, permissionId: 9 },
{ id: 10, userId: 1, permissionId: 10 },
{ id: 11, userId: 1, permissionId: 11 },
{ id: 12, userId: 1, permissionId: 12 },
{ id: 13, userId: 1, permissionId: 13 },
{ id: 14, userId: 1, permissionId: 14 },
{ id: 15, userId: 1, permissionId: 15 },
// John Doe (id: 2) - Customer Manager
{ id: 16, userId: 2, permissionId: 4 }, // customer_read
{ id: 17, userId: 2, permissionId: 5 }, // customer_write
{ id: 18, userId: 2, permissionId: 8 }, // reports_view
{ id: 19, userId: 2, permissionId: 11 }, // mail_template_read
// Jane Smith (id: 3) - User Manager
{ id: 20, userId: 3, permissionId: 1 }, // user_read
{ id: 21, userId: 3, permissionId: 2 }, // user_write
{ id: 22, userId: 3, permissionId: 14 }, // permission_manage
{ id: 23, userId: 3, permissionId: 8 }, // reports_view
// Mike Johnson (id: 4) - Reports Analyst
{ id: 24, userId: 4, permissionId: 4 }, // customer_read
{ id: 25, userId: 4, permissionId: 1 }, // user_read
{ id: 26, userId: 4, permissionId: 8 }, // reports_view
{ id: 27, userId: 4, permissionId: 9 }, // reports_export
{ id: 28, userId: 4, permissionId: 15 }, // audit_log
// Sarah Wilson (id: 5) - Customer Support
{ id: 29, userId: 5, permissionId: 4 }, // customer_read
{ id: 30, userId: 5, permissionId: 5 }, // customer_write
{ id: 31, userId: 5, permissionId: 11 }, // mail_template_read
{ id: 32, userId: 5, permissionId: 12 }, // mail_template_write
// David Brown (id: 6) - System Administrator
{ id: 33, userId: 6, permissionId: 7 }, // admin_panel
{ id: 34, userId: 6, permissionId: 10 }, // system_settings
{ id: 35, userId: 6, permissionId: 15 }, // audit_log
{ id: 36, userId: 6, permissionId: 1 }, // user_read
{ id: 37, userId: 6, permissionId: 2 }, // user_write
// Emma Davis (id: 7) - Content Manager
{ id: 38, userId: 7, permissionId: 11 }, // mail_template_read
{ id: 39, userId: 7, permissionId: 12 }, // mail_template_write
{ id: 40, userId: 7, permissionId: 13 }, // mail_template_delete
{ id: 41, userId: 7, permissionId: 4 }, // customer_read
// Alex Martinez (id: 8) - Junior Developer
{ id: 42, userId: 8, permissionId: 4 }, // customer_read
{ id: 43, userId: 8, permissionId: 1 }, // user_read
{ id: 44, userId: 8, permissionId: 11 }, // mail_template_read
// Lisa Garcia (id: 9) - Senior Support
{ id: 45, userId: 9, permissionId: 4 }, // customer_read
{ id: 46, userId: 9, permissionId: 5 }, // customer_write
{ id: 47, userId: 9, permissionId: 6 }, // customer_delete
{ id: 48, userId: 9, permissionId: 8 }, // reports_view
{ id: 49, userId: 9, permissionId: 11 }, // mail_template_read
// Robert Taylor (id: 10) - Data Analyst
{ id: 50, userId: 10, permissionId: 4 }, // customer_read
{ id: 51, userId: 10, permissionId: 1 }, // user_read
{ id: 52, userId: 10, permissionId: 8 }, // reports_view
{ id: 53, userId: 10, permissionId: 9 }, // reports_export
// Maria Rodriguez (id: 11) - Customer Service Lead
{ id: 54, userId: 11, permissionId: 4 }, // customer_read
{ id: 55, userId: 11, permissionId: 5 }, // customer_write
{ id: 56, userId: 11, permissionId: 8 }, // reports_view
{ id: 57, userId: 11, permissionId: 11 }, // mail_template_read
{ id: 58, userId: 11, permissionId: 12 }, // mail_template_write
];

View File

@@ -0,0 +1,94 @@
import { User } from "./database.schema";
export const sampleUsers: User[] = [
{
id: 2,
username: "john.doe",
email: "john.doe@example.com",
firstName: "John",
lastName: "Doe",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 3,
username: "jane.smith",
email: "jane.smith@example.com",
firstName: "Jane",
lastName: "Smith",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 4,
username: "mike.johnson",
email: "mike.johnson@example.com",
firstName: "Mike",
lastName: "Johnson",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 5,
username: "sarah.wilson",
email: "sarah.wilson@example.com",
firstName: "Sarah",
lastName: "Wilson",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 6,
username: "david.brown",
email: "david.brown@example.com",
firstName: "David",
lastName: "Brown",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 7,
username: "emma.davis",
email: "emma.davis@example.com",
firstName: "Emma",
lastName: "Davis",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 8,
username: "alex.martinez",
email: "alex.martinez@example.com",
firstName: "Alex",
lastName: "Martinez",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 9,
username: "lisa.garcia",
email: "lisa.garcia@example.com",
firstName: "Lisa",
lastName: "Garcia",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 10,
username: "robert.taylor",
email: "robert.taylor@example.com",
firstName: "Robert",
lastName: "Taylor",
password: "password123", // You should hash this in production
isDeleted: false,
},
{
id: 11,
username: "maria.rodriguez",
email: "maria.rodriguez@example.com",
firstName: "Maria",
lastName: "Rodriguez",
password: "password123", // You should hash this in production
isDeleted: false,
},
];

19
src/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});
export type LoginSchema = typeof loginSchema;

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
export const customerInfoSchema = z.object({
firstNameEn: z.string().min(1, "First name is required"),
lastNameEn: z.string().min(1, "Last name is required"),
originAdd1: z.string().min(1, "Origin address is required"),
localAdd1: z.string().min(1, "Local address is required"),
});
export const customerContactSchema = z.object({
email: z.string().email("Invalid email address"),
mobile: z.string().min(1, "Mobile number is required"),
});
export const customerDependantInfoSchema = z.object({
firstNameEn: z.string().min(1, "First name is required"),
lastNameEn: z.string().min(1, "Last name is required"),
originAdd1: z.string().min(1, "Origin address is required"),
localAdd1: z.string().min(1, "Local address is required"),
});
export const customerDependantContactSchema = z.object({
email: z.string().email("Invalid email address"),
mobile: z.string().min(1, "Mobile number is required"),
});
export const customerDependantFormSchema = z.object({
id: z.number().optional(),
dependantInfo: customerDependantInfoSchema,
dependantContact: customerDependantContactSchema,
});
export const customerFormSchema = z.object({
customerInfo: customerInfoSchema,
customerContact: customerContactSchema,
customerDependants: z.array(customerDependantFormSchema),
});
export type CustomerForm = z.infer<typeof customerFormSchema>;
export type CustomerDependantForm = z.infer<typeof customerDependantFormSchema>;

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
export const userFormSchema = z.object({
username: z.string().min(1, "Username is required").min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const userEditFormSchema = z.object({
username: z.string().min(1, "Username is required").min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
password: z.string().optional(),
confirmPassword: z.string().optional(),
}).refine((data) => {
if (data.password && data.password.length > 0) {
return data.password.length >= 6;
}
return true;
}, {
message: "Password must be at least 6 characters",
path: ["password"],
}).refine((data) => {
if (data.password && data.password.length > 0) {
return data.password === data.confirmPassword;
}
return true;
}, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export type UserForm = z.infer<typeof userFormSchema>;
export type UserEditForm = z.infer<typeof userEditFormSchema>;