first commit
This commit is contained in:
commit
562f67c5c0
|
|
@ -0,0 +1 @@
|
|||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Copy this file to .env.prisma and set your DATABASE_URL
|
||||
# Example:
|
||||
# DATABASE_URL="postgresql://user:pass@host:port/dbname"
|
||||
|
||||
DATABASE_URL="postgresql://postgres:gEJIfovgvDAroHhqRiKhYVvrkn2OqXfF3tw8xJmnvw7JhrZgN24pTD9iWMIUIUL6@prasi.avolut.com:8741/kig-bank"
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"tables": {
|
||||
"banks": {
|
||||
"fields": {
|
||||
"id": "number",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"fields": {
|
||||
"id": "number",
|
||||
"username": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "next-express-ts-app-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"generate:db-types": "node scripts/generate-db-types.js",
|
||||
"prisma:pull": "npx prisma db pull --schema=prisma/schema.prisma --print",
|
||||
"generate:db-types:prisma": "node scripts/prisma-to-dbtypes.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@prisma/client": "^6.17.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/lodash.get": "^4.4.9",
|
||||
"@types/lodash.uniqby": "^4.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.uniqby": "^4.7.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "latest",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "latest",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "latest",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-select": "^5.8.0",
|
||||
"react-select-async-paginate": "^0.7.9",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/classnames": "^2.3.0",
|
||||
"@types/node": "24.7.2",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"prisma": "^6.17.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "./node_modules/@prisma/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = "postgresql://postgres:gEJIfovgvDAroHhqRiKhYVvrkn2OqXfF3tw8xJmnvw7JhrZgN24pTD9iWMIUIUL6@prasi.avolut.com:8741/kig-bank"
|
||||
}
|
||||
|
||||
model access {
|
||||
access_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
access_type String @db.VarChar(50)
|
||||
access_value String
|
||||
is_created Boolean @default(false)
|
||||
is_updated Boolean @default(false)
|
||||
is_deleted Boolean @default(false)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
read_only Boolean @default(true)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@unique([user_id, access_type])
|
||||
}
|
||||
|
||||
model activity_logs {
|
||||
log_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
action String @db.VarChar(100)
|
||||
status String @db.VarChar(50)
|
||||
message String?
|
||||
extra_json Json?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model auth_logs {
|
||||
log_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
action String @db.VarChar(100)
|
||||
status String @db.VarChar(50)
|
||||
message String?
|
||||
extra_json Json?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model banks {
|
||||
bank_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
code String @unique @db.VarChar(50)
|
||||
name String @db.VarChar(150)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users[]
|
||||
}
|
||||
|
||||
model dashboards {
|
||||
dashboard_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
title String @db.VarChar(150)
|
||||
value Decimal @db.Decimal(20, 2)
|
||||
date DateTime @default(now()) @db.Timestamptz(6)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model parameters {
|
||||
parameter_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
param_key String @db.VarChar(100)
|
||||
param_value String
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@unique([user_id, param_key])
|
||||
}
|
||||
|
||||
model sessions {
|
||||
session_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
token_hash String @db.VarChar(128)
|
||||
expires_at DateTime? @db.Timestamptz(6)
|
||||
is_revoked Boolean @default(false)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@index([token_hash])
|
||||
@@index([user_id])
|
||||
}
|
||||
|
||||
model tenants {
|
||||
tenant_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
code String @unique @db.VarChar(50)
|
||||
name String @db.VarChar(150)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users?
|
||||
}
|
||||
|
||||
model user_access {
|
||||
access_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @db.Uuid
|
||||
access_type String @db.VarChar(50)
|
||||
access_value String
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [user_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
|
||||
@@unique([user_id, access_type])
|
||||
}
|
||||
|
||||
model users {
|
||||
user_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
tenant_id String @unique @db.Uuid
|
||||
bank_id String @db.Uuid
|
||||
partnerserviceid String?
|
||||
client_id String?
|
||||
client_secret String?
|
||||
api_key String?
|
||||
api_secret String?
|
||||
username String?
|
||||
password String?
|
||||
token_access String?
|
||||
token_refresh String?
|
||||
token_expiry DateTime? @db.Timestamptz(6)
|
||||
extra_json Json?
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
db_id String? @db.Uuid
|
||||
last_login DateTime? @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
access access[]
|
||||
activity_logs activity_logs[]
|
||||
auth_logs auth_logs[]
|
||||
dashboards dashboards[]
|
||||
parameters parameters[]
|
||||
sessions sessions[]
|
||||
user_access user_access[]
|
||||
banks banks @relation(fields: [bank_id], references: [bank_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
database database? @relation(fields: [db_id], references: [db_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
tenants tenants @relation(fields: [tenant_id], references: [tenant_id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model database {
|
||||
db_id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String @unique @db.VarChar(100)
|
||||
host String @db.VarChar(100)
|
||||
port Int
|
||||
username String @db.VarChar(100)
|
||||
password String @db.VarChar(100)
|
||||
db_name String @db.VarChar(100)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
users users[]
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const schemaPath = path.resolve(process.cwd(), "db-schema.json");
|
||||
const outPath = path.resolve(process.cwd(), "src", "types", "db-client.d.ts");
|
||||
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
console.error("db-schema.json not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||
const tables = schema.tables || {};
|
||||
|
||||
function fieldTypeToTs(t) {
|
||||
switch (t) {
|
||||
case "number":
|
||||
return "number";
|
||||
case "string":
|
||||
return "string";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
default:
|
||||
return "any";
|
||||
}
|
||||
}
|
||||
|
||||
let out = `// GENERATED FILE — edit db-schema.json and run npm run generate:db-types\n\n`;
|
||||
out += `type DBAction<TRequest = any, TResponse = any> = (payload?: TRequest) => Promise<TResponse>;\n\n`;
|
||||
|
||||
out += `// Generated tables\n`;
|
||||
out += `interface DBTables {\n`;
|
||||
for (const [table, def] of Object.entries(tables)) {
|
||||
out += ` ${table}: {\n`;
|
||||
out += ` create: DBAction<{ data: Partial<{`;
|
||||
const fields = def.fields || {};
|
||||
const fPairs = Object.entries(fields).map(
|
||||
([n, t]) => `${n}: ${fieldTypeToTs(t)}`
|
||||
);
|
||||
out += fPairs.join("; ");
|
||||
out += `}> }, any>;\n`;
|
||||
out += ` update: DBAction<{ where: any; data: any }, any>;\n`;
|
||||
out += ` delete: DBAction<{ where: any }, any>;\n`;
|
||||
out += ` findFirst: DBAction<{ where?: any; include?: any; select?: any }, any>;\n`;
|
||||
out += ` findMany: DBAction<any, any[]>;\n`;
|
||||
out += ` };\n`;
|
||||
}
|
||||
out += `}\n\n`;
|
||||
|
||||
out += `type DBClient = {\n [K in keyof DBTables]: DBTables[K];\n} & { [table: string]: { [action: string]: DBAction<any, any> } };\n\n`;
|
||||
|
||||
out += `declare module "@/lib/dbClient" {\n const db: DBClient;\n export default db;\n}\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, out, "utf8");
|
||||
console.log("Wrote", outPath);
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// This script reads prisma/schema.prisma and extracts model names, then
|
||||
// generates src/types/db-client.d.ts similar to the JSON generator.
|
||||
|
||||
const schemaPath = path.resolve(process.cwd(), "prisma", "schema.prisma");
|
||||
const outPath = path.resolve(process.cwd(), "src", "types", "db-client.d.ts");
|
||||
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
console.error("prisma/schema.prisma not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(schemaPath, "utf8");
|
||||
|
||||
// Very simple parser: find `model <Name> {` occurrences
|
||||
const modelRegex = /model\s+(\w+)\s+\{/g;
|
||||
let match;
|
||||
const models = [];
|
||||
while ((match = modelRegex.exec(content)) !== null) {
|
||||
models.push(match[1]);
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
console.warn(
|
||||
"No models found in prisma/schema.prisma. Run `npx prisma db pull` first."
|
||||
);
|
||||
}
|
||||
|
||||
function toTableName(model) {
|
||||
// Use the model name lowercased with no modifications so table names
|
||||
// match the model names exactly (except for case).
|
||||
return model.toLowerCase();
|
||||
}
|
||||
|
||||
let out = `// GENERATED FILE — derived from prisma/schema.prisma (run scripts/prisma-to-dbtypes.js)\n\n`;
|
||||
out += `type DBAction<TRequest = any, TResponse = any> = (payload?: TRequest) => Promise<TResponse>;\n\n`;
|
||||
|
||||
out += `interface DBTables {\n`;
|
||||
for (const m of models) {
|
||||
const t = toTableName(m);
|
||||
out += ` ${t}: {\n`;
|
||||
out += ` create: DBAction<{ data: any }, any>;\n`;
|
||||
out += ` update: DBAction<{ where: any; data: any }, any>;\n`;
|
||||
out += ` delete: DBAction<{ where: any }, any>;\n`;
|
||||
out += ` findFirst: DBAction<{ where?: any; include?: any; select?: any }, any>;\n`;
|
||||
out += ` findMany: DBAction<any, any[]>;\n`;
|
||||
out += ` };\n`;
|
||||
}
|
||||
out += `}\n\n`;
|
||||
|
||||
out += `type DBClient = {\n [K in keyof DBTables]: DBTables[K];\n} & { [table: string]: { [action: string]: DBAction<any, any> } };\n\n`;
|
||||
|
||||
out += `declare module "@/lib/dbClient" {\n const db: DBClient;\n export default db;\n}\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, out, "utf8");
|
||||
console.log("Wrote", outPath, "with models:", models.join(", "));
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import BankEditClient from "@/components/banks/BankEditClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Master Data - Bank",
|
||||
};
|
||||
|
||||
export default async function Page({ params }: { params: { id: string } }) {
|
||||
const { id } = params;
|
||||
|
||||
return (
|
||||
<Protected>
|
||||
{/* document/tab title */}
|
||||
<SetPageTitle title={id === "new" ? "Create Bank" : "Edit Bank"} />
|
||||
{/* header title / breadcrumb */}
|
||||
<SetHeaderTitle
|
||||
title={
|
||||
id === "new"
|
||||
? [{ label: "Banks", url: "/d/bank" }, { label: "Create" }]
|
||||
: [{ label: "Banks", url: "/d/bank" }, { label: "Edit" }]
|
||||
}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<BankEditClient id={id} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import RemoteDataTable from "@/components/RemoteDataTable";
|
||||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
|
||||
export const metadata = {
|
||||
title: "Master Data - Bank",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Protected>
|
||||
{/* document/tab title */}
|
||||
<SetPageTitle title="Master Data - Bank" />
|
||||
{/* header title / breadcrumb */}
|
||||
<SetHeaderTitle title="Banks" />
|
||||
<div className="p-2">
|
||||
<RemoteDataTable
|
||||
table="banks"
|
||||
idField="bank_id"
|
||||
columns={[
|
||||
{ name: "name", label: "Bank Name" },
|
||||
{ name: "code", label: "Bank Code" },
|
||||
]}
|
||||
// pass a serializable URL pattern; client will resolve :field or {field}
|
||||
urlData="/d/bank/:bank_id"
|
||||
addSectionLabel="Add Bank"
|
||||
addSectionUrl="/d/bank/new"
|
||||
/>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { ChartAreaInteractive } from "@/components/chart-area-interactive";
|
||||
import { SectionCards } from "@/components/section-cards";
|
||||
import Protected from "@/components/Protected";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Protected>
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<SectionCards />
|
||||
<div className="px-4 lg:px-6">
|
||||
<ChartAreaInteractive />
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import DatabaseFormClient from "@/components/DatabaseFormClient";
|
||||
import { callDbServer } from "@/lib/dbServer";
|
||||
|
||||
export default async function Page({ params }: { params: any }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (id === "new") {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Database - New" />
|
||||
<SetHeaderTitle
|
||||
title={[
|
||||
{ label: "Databases", url: "/d/database" },
|
||||
{ label: "Create" },
|
||||
]}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{/* @ts-ignore client component */}
|
||||
<DatabaseFormClient id={id} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
||||
const rec = await callDbServer("database", "findFirst", {
|
||||
where: { db_id: id },
|
||||
});
|
||||
|
||||
if (!rec) {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Database - Not Found" />
|
||||
<SetHeaderTitle title="Database - Not Found" />
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold">Data not found</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The database record does not exist.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="/d/database" className="text-primary underline">
|
||||
Back to Databases
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title={`Database - ${rec?.name ?? id}`} />
|
||||
<SetHeaderTitle
|
||||
title={
|
||||
id === "new"
|
||||
? [{ label: "Database", url: "/d/database" }, { label: "Create" }]
|
||||
: [{ label: "Database", url: "/d/database" }, { label: "Detail" }]
|
||||
}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{/* @ts-ignore client component */}
|
||||
<DatabaseFormClient id={id} defaults={rec} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import RemoteDataTable from "@/components/RemoteDataTable";
|
||||
|
||||
export const metadata = {
|
||||
title: "Master Data - Database",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Master Data - Database" />
|
||||
<SetHeaderTitle title="Databases" />
|
||||
<div className="p-2">
|
||||
<RemoteDataTable
|
||||
table="database"
|
||||
idField="db_id"
|
||||
columns={[
|
||||
{ name: "name", label: "Name" },
|
||||
{ name: "host", label: "Host" },
|
||||
{ name: "db_name", label: "Database" },
|
||||
]}
|
||||
urlData="/d/database/:id"
|
||||
addSectionLabel="Add Database"
|
||||
addSectionUrl="/d/database/new"
|
||||
/>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import "@/app/globals.css";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import Protected from "@/components/Protected";
|
||||
export const metadata = {
|
||||
title: "Next.js",
|
||||
description: "Generated by Next.js",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Protected>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import TenantEditClient from "@/components/TenantEditClient";
|
||||
import { callDbServer } from "@/lib/dbServer";
|
||||
|
||||
export default async function Page({ params }: { params: any }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (id === "new") {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Tenant - New" />
|
||||
<SetHeaderTitle
|
||||
title={[{ label: "Tenants", url: "/d/tenant" }, { label: "Create" }]}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{/* client handles create */}
|
||||
{/* @ts-ignore client component */}
|
||||
<TenantEditClient id={id} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
||||
// fetch existing tenant for edit
|
||||
// fetch existing tenant for edit (server-side helper)
|
||||
const tenant = await callDbServer("tenants", "findFirst", {
|
||||
where: { tenant_id: id },
|
||||
});
|
||||
if (!tenant) {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Tenant - Not Found" />
|
||||
<SetHeaderTitle title="Tenant - Not Found" />
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold">Data not found</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The tenant you are looking for does not exist or has been removed.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="/d/tenant" className="text-primary underline">
|
||||
Back to Tenants
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title={`Tenant - ${tenant?.name ?? id}`} />
|
||||
<SetHeaderTitle title={`Tenant - ${tenant?.name ?? id}`} />
|
||||
<SetHeaderTitle
|
||||
title={
|
||||
id === "new"
|
||||
? [{ label: "Tenants", url: "/d/tenant" }, { label: "Create" }]
|
||||
: [{ label: "Tenants", url: "/d/tenant" }, { label: "Detail" }]
|
||||
}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{/* @ts-ignore client component */}
|
||||
<TenantEditClient id={id} defaults={tenant} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import RemoteDataTable from "@/components/RemoteDataTable";
|
||||
|
||||
export const metadata = {
|
||||
title: "Master Data - Tenant",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Master Data - Tenant" />
|
||||
<SetHeaderTitle title="Tenants" />
|
||||
<div className="p-2">
|
||||
<RemoteDataTable
|
||||
table="tenants"
|
||||
idField="tenant_id"
|
||||
columns={[
|
||||
{ name: "name", label: "Tenant Name" },
|
||||
{ name: "code", label: "Code" },
|
||||
]}
|
||||
urlData="/d/tenant/:tenant_id"
|
||||
addSectionLabel="Add Tenant"
|
||||
addSectionUrl="/d/tenant/new"
|
||||
/>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import { callDbServer } from "@/lib/dbServer";
|
||||
import ParameterFormClient from "@/components/ParameterFormClient";
|
||||
|
||||
export default async function Page({ params }: { params: any }) {
|
||||
const { id_child, id, name } = await params;
|
||||
if (id_child !== "new") {
|
||||
if (name == "access") {
|
||||
const access = await callDbServer("user_access", "findFirst", {
|
||||
where: { access_id: id_child },
|
||||
});
|
||||
if (!access) {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="User Access - Not Found" />
|
||||
<SetHeaderTitle title="User Access - Not Found" />
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold">Data not found</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The user access you are looking for does not exist.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="/d/user" className="text-primary underline">
|
||||
Back to User Access
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
} else if (name == "parameter") {
|
||||
const parameter = await callDbServer("parameters", "findFirst", {
|
||||
where: { parameter_id: id_child },
|
||||
});
|
||||
if (!parameter) {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="User Parameter - Not Found" />
|
||||
<SetHeaderTitle title="User Parameter - Not Found" />
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold">Data not found</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The user parameter you are looking for does not exist.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="/d/user" className="text-primary underline">
|
||||
Back to User Parameters
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (name == "access") {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title={`User Access - ${id_child}`} />
|
||||
<SetHeaderTitle
|
||||
title={
|
||||
id_child === "new"
|
||||
? [
|
||||
{ label: "User", url: "/d/user" },
|
||||
{ label: "Detail", url: `/d/user/${id}` },
|
||||
{ label: "Create Access" },
|
||||
]
|
||||
: [
|
||||
{ label: "User", url: "/d/user" },
|
||||
{ label: "Detail", url: `/d/user/${id}` },
|
||||
{ label: "Detail Access" },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div className="p-2">{/* <ParameterFormClient id={id_child} /> */}</div>
|
||||
</Protected>
|
||||
);
|
||||
} else if (name == "parameter") {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title={`User Parameter - ${id_child}`} />
|
||||
<SetHeaderTitle
|
||||
title={
|
||||
id_child === "new"
|
||||
? [
|
||||
{ label: "User", url: "/d/user" },
|
||||
{ label: "Detail", url: `/d/user/${id}` },
|
||||
{ label: "Create Parameter" },
|
||||
]
|
||||
: [
|
||||
{ label: "User", url: "/d/user" },
|
||||
{ label: "Detail", url: `/d/user/${id}` },
|
||||
{ label: "Detail Parameter" },
|
||||
]
|
||||
}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<ParameterFormClient id={id_child} userId={id} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title={`User - ${id_child}`} />
|
||||
<SetHeaderTitle
|
||||
title={[
|
||||
{ label: "User", url: "/d/user" },
|
||||
{ label: "User", url: `/d/user/${id}` },
|
||||
{ label: "Detail Parameter" },
|
||||
]}
|
||||
/>
|
||||
<div className="p-2">Invalid URL</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import { callDbServer } from "@/lib/dbServer";
|
||||
import UserFormClient from "@/components/UserFormClient";
|
||||
|
||||
export default async function Page({ params }: { params: any }) {
|
||||
const { id } = await params;
|
||||
if (id !== "new") {
|
||||
const user = await callDbServer("users", "findFirst", {
|
||||
where: { user_id: id },
|
||||
});
|
||||
if (!user) {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="User - Not Found" />
|
||||
<SetHeaderTitle title="User - Not Found" />
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold">Data not found</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The user you are looking for does not exist.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="/d/user" className="text-primary underline">
|
||||
Back to Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title={`User - ${id}`} />
|
||||
<SetHeaderTitle
|
||||
title={
|
||||
id === "new"
|
||||
? [{ label: "User", url: "/d/user" }, { label: "Create" }]
|
||||
: [{ label: "User", url: "/d/user" }, { label: "Detail" }]
|
||||
}
|
||||
/>
|
||||
<div className="p-2 flex flex-col flex-grow">
|
||||
{/* client form handles callbacks */}
|
||||
{/* @ts-ignore client component */}
|
||||
<UserFormClient id={id} />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import Protected from "@/components/Protected";
|
||||
import SetPageTitle from "@/components/SetPageTitle";
|
||||
import SetHeaderTitle from "@/components/SetHeaderTitle";
|
||||
import UsersManager from "@/components/UsersManager";
|
||||
|
||||
export const metadata = { title: "Master Data - User" };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Protected>
|
||||
<SetPageTitle title="Master Data - User" />
|
||||
<SetHeaderTitle title="Users" />
|
||||
<div className="p-2">
|
||||
<UsersManager />
|
||||
</div>
|
||||
</Protected>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
[
|
||||
{
|
||||
"id": 1,
|
||||
"header": "Cover page",
|
||||
"type": "Cover page",
|
||||
"status": "In Process",
|
||||
"target": "18",
|
||||
"limit": "5",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"header": "Table of contents",
|
||||
"type": "Table of contents",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "24",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"header": "Executive summary",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "10",
|
||||
"limit": "13",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"header": "Technical approach",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "27",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"header": "Design",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "2",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"header": "Capabilities",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "20",
|
||||
"limit": "8",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"header": "Integration with existing systems",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "21",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"header": "Innovation and Advantages",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "25",
|
||||
"limit": "26",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"header": "Overview of EMR's Innovative Solutions",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "7",
|
||||
"limit": "23",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"header": "Advanced Algorithms and Machine Learning",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "28",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"header": "Adaptive Communication Protocols",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "9",
|
||||
"limit": "31",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"header": "Advantages Over Current Technologies",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "0",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"header": "Past Performance",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "33",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"header": "Customer Feedback and Satisfaction Levels",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "34",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"header": "Implementation Challenges and Solutions",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "3",
|
||||
"limit": "35",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"header": "Security Measures and Data Protection Policies",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "6",
|
||||
"limit": "36",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"header": "Scalability and Future Proofing",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "4",
|
||||
"limit": "37",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"header": "Cost-Benefit Analysis",
|
||||
"type": "Plain language",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "38",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"header": "User Training and Onboarding Experience",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "17",
|
||||
"limit": "39",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"header": "Future Development Roadmap",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "11",
|
||||
"limit": "40",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"header": "System Architecture Overview",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "24",
|
||||
"limit": "18",
|
||||
"reviewer": "Maya Johnson"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"header": "Risk Management Plan",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "15",
|
||||
"limit": "22",
|
||||
"reviewer": "Carlos Rodriguez"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"header": "Compliance Documentation",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "31",
|
||||
"limit": "27",
|
||||
"reviewer": "Sarah Chen"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"header": "API Documentation",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "8",
|
||||
"limit": "12",
|
||||
"reviewer": "Raj Patel"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"header": "User Interface Mockups",
|
||||
"type": "Visual",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "25",
|
||||
"reviewer": "Leila Ahmadi"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"header": "Database Schema",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "20",
|
||||
"reviewer": "Thomas Wilson"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"header": "Testing Methodology",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "14",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"header": "Deployment Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "30",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"header": "Budget Breakdown",
|
||||
"type": "Financial",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"header": "Market Analysis",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Sophia Martinez"
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"header": "Competitor Comparison",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"header": "Maintenance Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "23",
|
||||
"reviewer": "Alex Thompson"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"header": "User Personas",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "24",
|
||||
"reviewer": "Nina Patel"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"header": "Accessibility Compliance",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"header": "Performance Metrics",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "David Kim"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"header": "Disaster Recovery Plan",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"header": "Third-party Integrations",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"header": "User Feedback Summary",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "15",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"header": "Localization Strategy",
|
||||
"type": "Narrative",
|
||||
"status": "In Process",
|
||||
"target": "12",
|
||||
"limit": "19",
|
||||
"reviewer": "Maria Garcia"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"header": "Mobile Compatibility",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "James Wilson"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"header": "Data Migration Plan",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"header": "Quality Assurance Protocols",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Priya Singh"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"header": "Stakeholder Analysis",
|
||||
"type": "Research",
|
||||
"status": "In Process",
|
||||
"target": "11",
|
||||
"limit": "14",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"header": "Environmental Impact Assessment",
|
||||
"type": "Research",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"header": "Intellectual Property Rights",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "17",
|
||||
"limit": "20",
|
||||
"reviewer": "Sarah Johnson"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"header": "Customer Support Framework",
|
||||
"type": "Narrative",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"header": "Version Control Strategy",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"header": "Continuous Integration Pipeline",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Michael Chen"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"header": "Regulatory Compliance",
|
||||
"type": "Legal",
|
||||
"status": "In Process",
|
||||
"target": "13",
|
||||
"limit": "16",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"header": "User Authentication System",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "28",
|
||||
"limit": "31",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"header": "Data Analytics Framework",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"header": "Cloud Infrastructure",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "16",
|
||||
"limit": "19",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"header": "Network Security Measures",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "29",
|
||||
"limit": "32",
|
||||
"reviewer": "Lisa Wong"
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"header": "Project Timeline",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "14",
|
||||
"limit": "17",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"header": "Resource Allocation",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"header": "Team Structure and Roles",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "20",
|
||||
"limit": "23",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"header": "Communication Protocols",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "15",
|
||||
"limit": "18",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"header": "Success Metrics",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "30",
|
||||
"limit": "33",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"header": "Internationalization Support",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "23",
|
||||
"limit": "26",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"header": "Backup and Recovery Procedures",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "18",
|
||||
"limit": "21",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"header": "Monitoring and Alerting System",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "25",
|
||||
"limit": "28",
|
||||
"reviewer": "Daniel Park"
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"header": "Code Review Guidelines",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "12",
|
||||
"limit": "15",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"header": "Documentation Standards",
|
||||
"type": "Technical content",
|
||||
"status": "In Process",
|
||||
"target": "27",
|
||||
"limit": "30",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"header": "Release Management Process",
|
||||
"type": "Planning",
|
||||
"status": "Done",
|
||||
"target": "22",
|
||||
"limit": "25",
|
||||
"reviewer": "Assign reviewer"
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"header": "Feature Prioritization Matrix",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "19",
|
||||
"limit": "22",
|
||||
"reviewer": "Emma Davis"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"header": "Technical Debt Assessment",
|
||||
"type": "Technical content",
|
||||
"status": "Done",
|
||||
"target": "24",
|
||||
"limit": "27",
|
||||
"reviewer": "Eddie Lake"
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"header": "Capacity Planning",
|
||||
"type": "Planning",
|
||||
"status": "In Process",
|
||||
"target": "21",
|
||||
"limit": "24",
|
||||
"reviewer": "Jamik Tashpulatov"
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"header": "Service Level Agreements",
|
||||
"type": "Legal",
|
||||
"status": "Done",
|
||||
"target": "26",
|
||||
"limit": "29",
|
||||
"reviewer": "Assign reviewer"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
redirect("/d/dashboard");
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--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);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--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.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import "@/app/globals.css";
|
||||
import ReduxProvider from "@/providers/ReduxProvider";
|
||||
import { PageTitleProvider } from "@/providers/PageTitleProvider";
|
||||
import GlobalCxProvider from "@/components/GlobalCxProvider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export const metadata = {
|
||||
title: "Next.js",
|
||||
description: "Generated by Next.js",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ReduxProvider>
|
||||
<PageTitleProvider>
|
||||
<GlobalCxProvider />
|
||||
|
||||
<Toaster position="top-right" />
|
||||
{children}
|
||||
</PageTitleProvider>
|
||||
</ReduxProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { GalleryVerticalEnd } from "lucide-react";
|
||||
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<a href="#" className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
Midsuit
|
||||
</a>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
// server-side redirect to /login
|
||||
redirect("/login");
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import TestDbClient from "@/components/TestDbClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Test DB Client",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Test DB Client</h1>
|
||||
<TestDbClient />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Select from "react-select";
|
||||
|
||||
type OptionType = { label: string; value: string };
|
||||
|
||||
export default function AsyncSelectPaginate({
|
||||
loader,
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: any) {
|
||||
const [options, setOptions] = React.useState<OptionType[]>([]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await loader?.(inputValue ?? "", 1);
|
||||
if (!mounted) return;
|
||||
const opts = (res && res.options) || [];
|
||||
setOptions(opts || []);
|
||||
setHasMore(Boolean(res && res.hasMore));
|
||||
setPage(1);
|
||||
} catch (e) {
|
||||
console.error("AsyncSelectPaginate load error", e);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [inputValue, loader]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || !hasMore) return;
|
||||
setLoading(true);
|
||||
const next = page + 1;
|
||||
try {
|
||||
const res = await loader?.(inputValue ?? "", next);
|
||||
const opts = (res && res.options) || [];
|
||||
setOptions((s) => [...s, ...(opts || [])]);
|
||||
setHasMore(Boolean(res && res.hasMore));
|
||||
setPage(next);
|
||||
} catch (e) {
|
||||
console.error("AsyncSelectPaginate loadMore error", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onInputChange={(val) => setInputValue(val)}
|
||||
onMenuScrollToBottom={loadMore}
|
||||
isLoading={loading}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import db from "@/lib/dbClient";
|
||||
|
||||
export default function BanksManager() {
|
||||
const [banks, setBanks] = React.useState<any[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [code, setCode] = React.useState("");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const fetchBanks = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await db.banks.findMany();
|
||||
setBanks(res ?? []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchBanks();
|
||||
}, [fetchBanks]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await db.banks.create({ data: { name, code } });
|
||||
setBanks((s) => [res, ...s]);
|
||||
setName("");
|
||||
setCode("");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (bank: any) => {
|
||||
try {
|
||||
const res = await db.banks.update({
|
||||
where: { bank_id: bank.bank_id },
|
||||
data: { is_active: !bank.is_active },
|
||||
});
|
||||
setBanks((s) => s.map((b) => (b.bank_id === bank.bank_id ? res : b)));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (bank: any) => {
|
||||
try {
|
||||
await db.banks.delete({ where: { bank_id: bank.bank_id } });
|
||||
setBanks((s) => s.filter((b) => b.bank_id !== bank.bank_id));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-semibold mb-4">Master Data — Banks</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
placeholder="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="border px-2 py-1 mr-2"
|
||||
/>
|
||||
<input
|
||||
placeholder="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="border px-2 py-1 mr-2"
|
||||
/>
|
||||
<button onClick={handleCreate} className="btn">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600 mb-2">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border p-2 text-left">Code</th>
|
||||
<th className="border p-2 text-left">Name</th>
|
||||
<th className="border p-2">Active</th>
|
||||
<th className="border p-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{banks.map((b) => (
|
||||
<tr key={b.bank_id}>
|
||||
<td className="border p-2">{b.code}</td>
|
||||
<td className="border p-2">{b.name}</td>
|
||||
<td className="border p-2 text-center">
|
||||
{b.is_active ? "Yes" : "No"}
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<button onClick={() => handleToggle(b)} className="mr-2">
|
||||
Toggle
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(b)}
|
||||
className="text-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type BaseFormProps<T extends Record<string, any>> = {
|
||||
defaultValues?: Partial<T>;
|
||||
onSubmit: (values: T) => Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
export default function BaseForm<T extends Record<string, any>>({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
children,
|
||||
submitLabel = "Save",
|
||||
}: BaseFormProps<T>) {
|
||||
const methods = useForm<T>({ defaultValues: defaultValues as any });
|
||||
|
||||
// If parent provides new defaultValues (e.g. after an async fetch), reset the form
|
||||
React.useEffect(() => {
|
||||
if (defaultValues) {
|
||||
methods.reset(defaultValues as any);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(defaultValues)]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(async (v: any) => {
|
||||
await onSubmit(v as T);
|
||||
})}
|
||||
className="space-y-4"
|
||||
>
|
||||
{children}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit">{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Form } from "@/components/form/Form";
|
||||
import { Field } from "@/components/form/Field";
|
||||
import { toast } from "sonner";
|
||||
import db from "@/lib/dbClient";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
type Props = { id?: string; defaults?: any };
|
||||
|
||||
export default function DatabaseFormClient({ id, defaults }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={async (fm: any) => {
|
||||
try {
|
||||
const vals = fm.data;
|
||||
if (!id || id === "new") {
|
||||
await db.database.create({ data: vals });
|
||||
toast.success("Database created");
|
||||
} else {
|
||||
await db.database.update({ where: { id }, data: vals });
|
||||
toast.success("Database updated");
|
||||
}
|
||||
router.push("/d/database");
|
||||
} catch (e: any) {
|
||||
toast.error(String(e?.message || e));
|
||||
}
|
||||
}}
|
||||
onLoad={async () => {
|
||||
if (id && id !== "new") {
|
||||
const rec = await db.database.findFirst({ where: { db_id: id } });
|
||||
return rec || {};
|
||||
}
|
||||
return {};
|
||||
}}
|
||||
showResize={false}
|
||||
header={(fm: any) => (
|
||||
<div className="flex-grow px-4 py-2 font-semibold flex flex-row items-center gap-2">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await fm.submit();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
||||
{id && id !== "new" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (!id || id === "new") return;
|
||||
const ok = confirm(
|
||||
"Are you sure you want to delete this user?"
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await db.users.delete({ where: { user_id: id } });
|
||||
toast.success("User deleted");
|
||||
router.push("/d/user");
|
||||
} catch (e: any) {
|
||||
toast.error(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={loading || !id || id === "new"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
children={(fm: any) => (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field fm={fm} name="name" label="Name" type="text" />
|
||||
<Field fm={fm} name="host" label="Host" type="text" />
|
||||
<Field fm={fm} name="port" label="Port" type="money" />
|
||||
<Field fm={fm} name="username" label="User" type="text" />
|
||||
<Field fm={fm} name="db_name" label="Database" type="text" />
|
||||
<Field fm={fm} name="password" label="Password" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const ExampleComponent: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>This is an Example Component</h1>
|
||||
<p>This component can be reused throughout the application.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleComponent;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import classnames from "classnames";
|
||||
import { css as emotionCss } from "@emotion/css";
|
||||
|
||||
export default function GlobalCxProvider() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
// expose as a global helper `cx` (same API as classnames)
|
||||
(globalThis as any).cx = classnames;
|
||||
// expose Emotion's css helper so files that use `css` template literals without local import work
|
||||
(globalThis as any).css = emotionCss;
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
import { Form } from "@/components/form/Form";
|
||||
import { Field } from "@/components/form/Field";
|
||||
import db from "@/lib/dbClient";
|
||||
import { toast } from "sonner";
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ParameterFormClient({
|
||||
userId,
|
||||
id,
|
||||
defaults,
|
||||
onSaved,
|
||||
}: any) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const isNew = !id || id === "new";
|
||||
return (
|
||||
<Form
|
||||
onLoad={async () => {
|
||||
if (isNew) return { param_key: "", param_value: "", user_id: userId };
|
||||
const data = await db.parameters.findFirst({
|
||||
where: { parameter_id: id },
|
||||
});
|
||||
return data || {};
|
||||
}}
|
||||
onSubmit={async (fm: any) => {
|
||||
const vals = fm.data;
|
||||
if (isNew) {
|
||||
console.log({ data: { ...vals, user_id: userId } });
|
||||
const data = await db.parameters.create({
|
||||
data: { ...vals, user_id: userId },
|
||||
});
|
||||
router.push(`/d/user/${userId}/parameter/${data.parameter_id}`);
|
||||
} else {
|
||||
await db.parameters.update({
|
||||
where: { parameter_id: id },
|
||||
data: vals,
|
||||
});
|
||||
toast.success("Parameter updated");
|
||||
}
|
||||
if (onSaved) onSaved();
|
||||
}}
|
||||
header={(fm: any) => (
|
||||
<>
|
||||
<div className="flex-grow px-4 py-2 font-semibold flex flex-row items-center gap-2">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await fm.submit();
|
||||
} catch (e: any) {
|
||||
setLoading(false);
|
||||
toast.error(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{id && id !== "new" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (!id || id === "new") return;
|
||||
const ok = confirm(
|
||||
"Are you sure you want to delete this user?"
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await db.parameters.delete({ where: { parameter_id: id } });
|
||||
toast.success("Parameter deleted");
|
||||
router.push(`/d/user/${userId}`);
|
||||
} catch (e: any) {
|
||||
toast.error(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={loading || !id || id === "new"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
showResize={false}
|
||||
children={(fm: any) => (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col gap-y-2 md:gap-y-0 md:flex-row flex-wrap px-4 py-2"
|
||||
}
|
||||
>
|
||||
<div className={"flex-grow grid gap-4 md:gap-6 md:grid-cols-2"}>
|
||||
<Field fm={fm} name="param_key" label="Key" type="text" />
|
||||
<Field fm={fm} name="param_value" label="Value" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
import RemoteDataTable from "@/components/RemoteDataTable";
|
||||
|
||||
export default function ParametersList({ userId }: { userId: string }) {
|
||||
return (
|
||||
<div className="">
|
||||
<RemoteDataTable
|
||||
table="parameters"
|
||||
idField="parameter_id"
|
||||
columns={[
|
||||
{ name: "param_key", label: "Key" },
|
||||
{ name: "param_value", label: "Value" },
|
||||
{ name: "is_active", label: "Active" },
|
||||
]}
|
||||
addSectionLabel="Add Parameter"
|
||||
addSectionUrl={`/d/user/${userId}/parameter/new`}
|
||||
urlData={`/d/user/${userId}/parameter/:parameter_id`}
|
||||
// note: RemoteDataTable will call backend /db with table/actions
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
[data-floating-ui-portal] > div {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
import {
|
||||
FloatingFocusManager,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
Placement,
|
||||
arrow,
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useId,
|
||||
useInteractions,
|
||||
useMergeRefs,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import * as React from "react";
|
||||
import "./Popover.css";
|
||||
import { css } from "@emotion/css";
|
||||
|
||||
interface PopoverOptions {
|
||||
initialOpen?: boolean;
|
||||
placement?: Placement;
|
||||
modal?: boolean;
|
||||
open?: boolean;
|
||||
offset?: number;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
autoFocus?: boolean;
|
||||
backdrop?: boolean | "self";
|
||||
root?: HTMLElement;
|
||||
}
|
||||
|
||||
export function usePopover({
|
||||
initialOpen = false,
|
||||
placement = "bottom",
|
||||
modal = false,
|
||||
open: controlledOpen,
|
||||
offset: popoverOffset = 5,
|
||||
onOpenChange: setControlledOpen,
|
||||
autoFocus = false,
|
||||
backdrop = true,
|
||||
root,
|
||||
}: PopoverOptions = {}) {
|
||||
const arrowRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);
|
||||
const [labelId, setLabelId] = React.useState<string | undefined>();
|
||||
const [descriptionId, setDescriptionId] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
// Determine whether the popover is open
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
const setOpen = setControlledOpen ?? setUncontrolledOpen;
|
||||
|
||||
// Floating UI setup
|
||||
const data = useFloating({
|
||||
placement,
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(popoverOffset),
|
||||
flip({
|
||||
fallbackAxisSideDirection: "end",
|
||||
padding: 8,
|
||||
}),
|
||||
shift({ padding: 5 }),
|
||||
arrow({ element: arrowRef }),
|
||||
],
|
||||
});
|
||||
|
||||
const context = data.context;
|
||||
|
||||
// Add interactions (click, dismiss, role)
|
||||
const click = useClick(context, {
|
||||
enabled: controlledOpen == null, // Only enable if not controlled
|
||||
});
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
|
||||
// Combine all interactions
|
||||
const interactions = useInteractions([click, dismiss, role]);
|
||||
|
||||
// Return memoized popover properties
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
...interactions,
|
||||
...data,
|
||||
arrowRef,
|
||||
modal,
|
||||
labelId,
|
||||
descriptionId,
|
||||
setLabelId,
|
||||
setDescriptionId,
|
||||
backdrop,
|
||||
autoFocus,
|
||||
root,
|
||||
}),
|
||||
[
|
||||
open,
|
||||
setOpen,
|
||||
interactions,
|
||||
data,
|
||||
modal,
|
||||
labelId,
|
||||
descriptionId,
|
||||
backdrop,
|
||||
autoFocus,
|
||||
root,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function mapPlacementSideToCSSProperty(placement: Placement) {
|
||||
const staticPosition = placement.split("-")[0];
|
||||
|
||||
const staticSide = {
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
}[staticPosition];
|
||||
|
||||
return staticSide;
|
||||
}
|
||||
function PopoverArrow() {
|
||||
const context = usePopoverContext();
|
||||
const { x: arrowX, y: arrowY } = context.middlewareData.arrow || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
const staticSide = mapPlacementSideToCSSProperty(context.placement) as string;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={context.arrowRef}
|
||||
style={{
|
||||
left: arrowX != null ? `${arrowX}px` : "",
|
||||
top: arrowY != null ? `${arrowY}px` : "",
|
||||
[staticSide]: "-4px",
|
||||
transform: "rotate(45deg)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={cx(
|
||||
"arrow",
|
||||
css`
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
`
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ContextType =
|
||||
| (ReturnType<typeof usePopover> & {
|
||||
setLabelId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setDescriptionId: React.Dispatch<
|
||||
React.SetStateAction<string | undefined>
|
||||
>;
|
||||
})
|
||||
| null;
|
||||
|
||||
const PopoverContext = React.createContext<ContextType>(null);
|
||||
|
||||
export const usePopoverContext = () => {
|
||||
const context = React.useContext(PopoverContext);
|
||||
|
||||
if (context == null) {
|
||||
throw new Error("Popover components must be wrapped in <Popover />");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function Popover({
|
||||
children,
|
||||
content,
|
||||
modal = false,
|
||||
className,
|
||||
classNameTrigger,
|
||||
arrow,
|
||||
popoverClassName,
|
||||
onMouseDown,
|
||||
...restOptions
|
||||
}: {
|
||||
root?: HTMLElement;
|
||||
className?: string;
|
||||
classNameTrigger?: string;
|
||||
children: React.ReactNode;
|
||||
content?: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
arrow?: boolean;
|
||||
onMouseDown?: (event: any) => void;
|
||||
} & PopoverOptions) {
|
||||
const popover = usePopover({ modal, ...restOptions });
|
||||
|
||||
let _content = content;
|
||||
if (!content) _content = <div className={"w-[300px] h-[150px]"}></div>;
|
||||
return (
|
||||
<PopoverContext.Provider value={popover}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className={cx("h-full cursor-pointer", classNameTrigger)}
|
||||
onClick={
|
||||
typeof restOptions.open !== "undefined"
|
||||
? () => {
|
||||
popover.setOpen(!popover.open);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{[children]}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cx(
|
||||
"pointer-events-auto",
|
||||
popoverClassName
|
||||
? popoverClassName
|
||||
: cx(
|
||||
className,
|
||||
css`
|
||||
background: white;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
|
||||
user-select: none;
|
||||
`
|
||||
)
|
||||
)}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{_content}
|
||||
{(typeof arrow === "undefined" || arrow) && <PopoverArrow />}
|
||||
</PopoverContent>
|
||||
</PopoverContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface PopoverTriggerProps {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const PopoverTrigger = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.HTMLProps<HTMLElement> & PopoverTriggerProps
|
||||
>(function PopoverTrigger({ children, asChild = false, ...props }, propRef) {
|
||||
const context = usePopoverContext();
|
||||
|
||||
// Gabungkan refs dari popover context dan ref prop
|
||||
const ref = useMergeRefs([context.refs.setReference, propRef]);
|
||||
|
||||
// `asChild` memungkinkan elemen anak digunakan sebagai anchor
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
ref,
|
||||
...context.getReferenceProps({
|
||||
...props,
|
||||
...children.props,
|
||||
"data-state": context.open ? "open" : "closed",
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-state={context.open ? "open" : "closed"}
|
||||
{...context.getReferenceProps(props as any)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PopoverContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
>(function PopoverContent(props, propRef) {
|
||||
const { context: floatingContext, ...context } = usePopoverContext();
|
||||
const ref = useMergeRefs([context.refs.setFloating, propRef]);
|
||||
|
||||
if (!floatingContext.open) return null;
|
||||
|
||||
const _content = (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...context.floatingStyles,
|
||||
...props.style,
|
||||
}}
|
||||
aria-labelledby={context.labelId}
|
||||
aria-describedby={context.descriptionId}
|
||||
{...context.getFloatingProps(props as any)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = context.autoFocus ? (
|
||||
<FloatingFocusManager context={floatingContext} modal={context.modal}>
|
||||
{_content}
|
||||
</FloatingFocusManager>
|
||||
) : (
|
||||
_content
|
||||
);
|
||||
|
||||
return (
|
||||
<FloatingPortal root={context.root}>
|
||||
{context.backdrop ? (
|
||||
<FloatingOverlay className={"z-50"} lockScroll>
|
||||
{content}
|
||||
</FloatingOverlay>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</FloatingPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export const PopoverHeading = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLProps<HTMLHeadingElement>
|
||||
>(function PopoverHeading({ children, ...props }, ref) {
|
||||
const { setLabelId } = usePopoverContext();
|
||||
const id = useId();
|
||||
|
||||
// Only sets `aria-labelledby` on the Popover root element
|
||||
// if this component is mounted inside it.
|
||||
React.useLayoutEffect(() => {
|
||||
setLabelId(id);
|
||||
return () => setLabelId(undefined);
|
||||
}, [id, setLabelId]);
|
||||
|
||||
return (
|
||||
<h2 {...props} ref={ref} id={id}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
});
|
||||
|
||||
export const PopoverDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLProps<HTMLParagraphElement>
|
||||
>(function PopoverDescription({ children, ...props }, ref) {
|
||||
const { setDescriptionId } = usePopoverContext();
|
||||
const id = useId();
|
||||
|
||||
// Only sets `aria-describedby` on the Popover root element
|
||||
// if this component is mounted inside it.
|
||||
React.useLayoutEffect(() => {
|
||||
setDescriptionId(id);
|
||||
return () => setDescriptionId(undefined);
|
||||
}, [id, setDescriptionId]);
|
||||
|
||||
return (
|
||||
<p {...props} ref={ref} id={id}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
|
||||
export const PopoverClose = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(function PopoverClose(props, ref) {
|
||||
const { setOpen } = usePopoverContext();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Protected({ children }: { children: React.ReactNode }) {
|
||||
const { token, hydrated } = useAppSelector((s) => s.auth);
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hydrated && !token) router.replace("/login");
|
||||
}, [hydrated, token, router]);
|
||||
|
||||
// Wait for hydration to avoid redirect flash
|
||||
if (!hydrated)
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import db from "@/lib/dbClient";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
|
||||
type ColumnDef = {
|
||||
name: string; // property path on row
|
||||
label: string;
|
||||
type?: "string" | "number" | "boolean" | "date" | "status" | "code";
|
||||
children?: ColumnDef[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
table: string;
|
||||
idField?: string; // e.g. 'bank_id'
|
||||
columns?: ColumnDef[];
|
||||
where?: any;
|
||||
search?: string;
|
||||
filters?: Record<string, any>;
|
||||
urlData?: ((data: any) => string) | string; // either a serializable URL pattern or a function
|
||||
addSectionLabel?: string;
|
||||
addSectionUrl?: string;
|
||||
include?: any;
|
||||
};
|
||||
|
||||
export default function RemoteDataTable({
|
||||
table,
|
||||
idField,
|
||||
columns,
|
||||
where,
|
||||
search,
|
||||
filters,
|
||||
include,
|
||||
urlData,
|
||||
addSectionLabel,
|
||||
addSectionUrl,
|
||||
}: Props) {
|
||||
const [rows, setRows] = React.useState<any[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = React.useState(0);
|
||||
const [pageSize, setPageSize] = React.useState(10);
|
||||
const [total, setTotal] = React.useState<number | null>(null);
|
||||
|
||||
const fetchPage = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const skip = pageIndex * pageSize;
|
||||
const take = pageSize;
|
||||
// build payload: send Prisma-like args plus optional where/search/filters
|
||||
const args: any = { skip, take };
|
||||
// include consumer-supplied where/search/filters
|
||||
if (typeof where !== "undefined") args.where = where;
|
||||
if (typeof search !== "undefined") args.search = search;
|
||||
if (typeof filters !== "undefined") args.filters = filters;
|
||||
if (typeof include !== "undefined") args.include = include;
|
||||
|
||||
// @ts-ignore dynamic access
|
||||
const res = await (db as any)[table].findMany(args);
|
||||
|
||||
// try to fetch total count from backend if endpoint supports it
|
||||
let fetchedTotal: number | null = null;
|
||||
try {
|
||||
// @ts-ignore dynamic access
|
||||
const c = await (db as any)[table].count?.({
|
||||
where: args.where ?? where,
|
||||
});
|
||||
if (typeof c === "number") fetchedTotal = c;
|
||||
else if (c && typeof c.count === "number") fetchedTotal = c;
|
||||
} catch (e) {
|
||||
// ignore count errors; we'll fallback to response shape
|
||||
}
|
||||
console.log(res);
|
||||
if (Array.isArray(res)) {
|
||||
setRows(res);
|
||||
setTotal(fetchedTotal ?? res.length);
|
||||
} else if (res && Array.isArray(res as any)) {
|
||||
setRows(res as any);
|
||||
setTotal(
|
||||
fetchedTotal ??
|
||||
(typeof (res as any) === "number"
|
||||
? (res as any)
|
||||
: (res as any).length)
|
||||
);
|
||||
} else if (res && Array.isArray(res as any)) {
|
||||
// some backends wrap in { result: [...] }
|
||||
setRows(res as any);
|
||||
setTotal(fetchedTotal ?? (res as any).length);
|
||||
} else {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [table, pageIndex, pageSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchPage();
|
||||
}, [fetchPage]);
|
||||
|
||||
const idKey = React.useMemo(() => idField ?? "", [idField]);
|
||||
|
||||
const cols: ColumnDef[] = columns && columns.length ? columns : [];
|
||||
|
||||
const mapped = rows.map((r: any, idx: number) => {
|
||||
const id = idKey ? r[idKey] ?? idx : pageIndex * pageSize + idx;
|
||||
|
||||
const getByPath = (obj: any, path: string) => {
|
||||
return path
|
||||
.split(".")
|
||||
.reduce((acc: any, k) => (acc ? acc[k] : undefined), obj);
|
||||
};
|
||||
const headerField = cols[1]?.name ?? cols[0]?.name ?? "name";
|
||||
const typeField = cols[0]?.name ?? "code";
|
||||
const header = getByPath(r, headerField) ?? String(id);
|
||||
const type = getByPath(r, typeField) ?? "";
|
||||
const status =
|
||||
typeof r.is_active !== "undefined"
|
||||
? r.is_active
|
||||
? "Done"
|
||||
: "In Progress"
|
||||
: "";
|
||||
let result = {
|
||||
id,
|
||||
...r,
|
||||
};
|
||||
return result;
|
||||
});
|
||||
const onDelete = React.useCallback(
|
||||
async (id: any) => {
|
||||
try {
|
||||
// @ts-ignore dynamic access
|
||||
await (db as any)[table].delete({ where: { [idField ?? "id"]: id } });
|
||||
// refresh
|
||||
fetchPage();
|
||||
} catch (e) {
|
||||
console.error("delete failed", e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[table, idField, fetchPage]
|
||||
);
|
||||
console.log(mapped);
|
||||
return (
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
Loading table...
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="py-2 text-sm text-red-600">{error}</div>}
|
||||
<DataTable
|
||||
data={mapped}
|
||||
columns={cols}
|
||||
urlData={urlData}
|
||||
onDelete={onDelete}
|
||||
addSectionLabel={addSectionLabel}
|
||||
addSectionUrl={addSectionUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePageTitle } from "@/providers/PageTitleProvider";
|
||||
|
||||
export default function SetHeaderTitle({
|
||||
title,
|
||||
resetTo = "Documents",
|
||||
}: {
|
||||
title: string | { label: string; url?: string }[];
|
||||
resetTo?: string | { label: string; url?: string }[] | string;
|
||||
}) {
|
||||
const { setTitle } = usePageTitle();
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(title as any);
|
||||
return () => setTitle(resetTo as any);
|
||||
}, [title, resetTo, setTitle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* SetPageTitle (document title)
|
||||
* This component updates the browser/document title (the website tab title).
|
||||
* It intentionally does NOT touch the header title provider.
|
||||
*
|
||||
* Usage: <SetPageTitle title="My Page" /> or pass an array of breadcrumb items;
|
||||
* when an array is provided the last breadcrumb label will be used as the page title.
|
||||
*/
|
||||
export default function SetPageTitle({
|
||||
title,
|
||||
resetTo = "App",
|
||||
}: {
|
||||
title: string | { label: string; url?: string }[];
|
||||
resetTo?: string | { label: string; url?: string }[] | string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const resolved = Array.isArray(title)
|
||||
? title.length
|
||||
? title[title.length - 1].label
|
||||
: String(title)
|
||||
: title;
|
||||
|
||||
const prev = document.title;
|
||||
document.title = String(resolved);
|
||||
|
||||
return () => {
|
||||
if (typeof resetTo === "string") document.title = resetTo;
|
||||
else if (Array.isArray(resetTo) && resetTo.length)
|
||||
document.title = resetTo[resetTo.length - 1].label;
|
||||
else document.title = prev;
|
||||
};
|
||||
}, [title, resetTo]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import db from "@/lib/dbClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
id?: string | null;
|
||||
defaults?: any;
|
||||
};
|
||||
|
||||
export default function TenantEditClient({ id, defaults }: Props) {
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit, reset } = useForm({
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaults) reset(defaults);
|
||||
}, [defaults, reset]);
|
||||
|
||||
const onSubmit = async (vals: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!id || id === "new") {
|
||||
await db.tenants.create({ data: vals });
|
||||
toast.success("Tenant created");
|
||||
} else {
|
||||
await db.tenants.update({ where: { tenant_id: id }, data: vals });
|
||||
toast.success("Tenant updated");
|
||||
}
|
||||
// client-side navigation for smoother UX
|
||||
router.push("/d/tenant");
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
setError(msg);
|
||||
toast.error("Save failed: " + msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="p-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
disabled={loading}
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<Label htmlFor="code">Code</Label>
|
||||
<Input id="code" disabled={loading} {...register("code")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 mb-2">{error}</div>}
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/d/tenant")}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import db from "@/lib/dbClient";
|
||||
|
||||
export default function TestDbClient() {
|
||||
const [name, setName] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [result, setResult] = React.useState<any>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await db.banks.create({ data: { name, code: name } });
|
||||
setResult(res);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Test DB Client</h2>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="bank name"
|
||||
className="border px-2 py-1 mr-2"
|
||||
/>
|
||||
<button onClick={handleCreate} disabled={loading} className="btn">
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<pre className="bg-gray-100 p-2 mt-2">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{error && <div className="text-red-600 mt-2">Error: {error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
import RemoteDataTable from "@/components/RemoteDataTable";
|
||||
|
||||
export default function UserAccessList({ userId }: { userId: string }) {
|
||||
return (
|
||||
<div className="">
|
||||
<RemoteDataTable
|
||||
table="user_access"
|
||||
idField="access_id"
|
||||
columns={[
|
||||
{ name: "access_type", label: "Type" },
|
||||
{ name: "access_value", label: "Value" },
|
||||
{ name: "is_active", label: "Active" },
|
||||
]}
|
||||
addSectionLabel="Add Access"
|
||||
addSectionUrl={`/d/user/${userId}/access/new`}
|
||||
urlData={`/d/user/${userId}/access/:access_id`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import db from "@/lib/dbClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = { id?: string | null; defaults?: any };
|
||||
|
||||
export default function UserEditClient({ id, defaults }: Props) {
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit, reset } = useForm({
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaults) reset(defaults);
|
||||
}, [defaults, reset]);
|
||||
|
||||
const onSubmit = async (vals: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!id || id === "new") {
|
||||
await db.users.create({ data: vals });
|
||||
toast.success("User created");
|
||||
} else {
|
||||
await db.users.update({ where: { user_id: id }, data: vals });
|
||||
toast.success("User updated");
|
||||
}
|
||||
router.push("/d/user");
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || String(e);
|
||||
setError(msg);
|
||||
toast.error("Save failed: " + msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="p-4 max-w-lg" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
disabled={loading}
|
||||
{...register("username", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
disabled={loading}
|
||||
{...register("email", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-600 mb-2">{error}</div>}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/d/user")}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Form } from "@/components/form/Form";
|
||||
import { Field } from "@/components/form/Field";
|
||||
import { toast } from "sonner";
|
||||
import db from "@/lib/dbClient";
|
||||
import { Button } from "./ui/button";
|
||||
import ParametersList from "@/components/ParametersList";
|
||||
import UserAccessList from "@/components/UserAccessList";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "./ui/resize";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
type Props = { id?: string; defaults?: any };
|
||||
|
||||
export default function UserFormClient({ id, defaults }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow">
|
||||
<ResizablePanelGroup direction="vertical" className=" rounded-lg ">
|
||||
<ResizablePanel defaultSize={id && id !== "new" ? 75 : 100}>
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<Form
|
||||
onSubmit={async (fm: any) => {
|
||||
const vals = fm.data;
|
||||
delete vals.tenants;
|
||||
delete vals.banks;
|
||||
delete vals.database;
|
||||
console.log("vals", vals);
|
||||
if (!id || id === "new") {
|
||||
await db.users.create({ data: vals });
|
||||
toast.success("User created");
|
||||
} else {
|
||||
await db.users.update({
|
||||
where: { user_id: id },
|
||||
data: vals,
|
||||
});
|
||||
// toast.success("User updated");
|
||||
}
|
||||
}}
|
||||
onLoad={async () => {
|
||||
if (id && id !== "new") {
|
||||
const user = await db.users.findFirst({
|
||||
select: {
|
||||
user_id: true,
|
||||
username: true,
|
||||
tenant_id: true,
|
||||
bank_id: true,
|
||||
private_key_file: true,
|
||||
public_key_file: true,
|
||||
pharaphrase: true,
|
||||
clientbank_id: true,
|
||||
db_id: true,
|
||||
tenants: true,
|
||||
banks: true,
|
||||
database: true,
|
||||
},
|
||||
where: { user_id: id },
|
||||
// include: { tenants: true, banks: true },
|
||||
});
|
||||
return user || {};
|
||||
}
|
||||
return {};
|
||||
}}
|
||||
showResize={false}
|
||||
header={(fm: any) => (
|
||||
<>
|
||||
<div className="flex-grow px-4 py-2 font-semibold flex flex-row items-center gap-2">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await fm.submit();
|
||||
} catch (e: any) {
|
||||
setLoading(false);
|
||||
toast.error(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{id && id !== "new" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (!id || id === "new") return;
|
||||
const ok = confirm(
|
||||
"Are you sure you want to delete this user?"
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await db.users.delete({ where: { user_id: id } });
|
||||
toast.success("User deleted");
|
||||
router.push("/d/user");
|
||||
} catch (e: any) {
|
||||
toast.error(String(e?.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={loading || !id || id === "new"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
children={(fm: any) => (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col gap-y-2 md:gap-y-0 md:flex-row flex-wrap px-4 py-2"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={"flex-grow grid gap-4 md:gap-6 md:grid-cols-2"}
|
||||
>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
target={"tenant_id"}
|
||||
name={"tenants"}
|
||||
label={"Tenant"}
|
||||
type={"dropdown-async"}
|
||||
onLoad={async (param: any) => {
|
||||
const res = await db.tenants.findMany({
|
||||
where: { name: { contains: param.search || "" } },
|
||||
take: param.take || 10,
|
||||
skip: param.skip || 0,
|
||||
});
|
||||
return res;
|
||||
}}
|
||||
onValue={"tenant_id"}
|
||||
onLabel={"name"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
target={"bank_id"}
|
||||
name={"banks"}
|
||||
label={"Bank"}
|
||||
type={"dropdown-async"}
|
||||
onLoad={async (param: any) => {
|
||||
const res = await db.banks.findMany({
|
||||
where: { name: { contains: param.search || "" } },
|
||||
take: param.take || 10,
|
||||
skip: param.skip || 0,
|
||||
});
|
||||
return res;
|
||||
}}
|
||||
onValue={"bank_id"}
|
||||
onLabel={"name"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"clientbank_id"}
|
||||
label={"Client Bank ID"}
|
||||
type={"text"}
|
||||
placeholder="Client Bank ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"client_id"}
|
||||
label={"Midsuit Client ID"}
|
||||
type={"text"}
|
||||
placeholder="Midsuit Client ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
target={"db_id"}
|
||||
name={"database"}
|
||||
label={"Database"}
|
||||
type={"dropdown-async"}
|
||||
onLoad={async (param: any) => {
|
||||
const res = await db.database.findMany({
|
||||
where: { name: { contains: param.search || "" } },
|
||||
take: param.take || 10,
|
||||
skip: param.skip || 0,
|
||||
});
|
||||
return res;
|
||||
}}
|
||||
onValue={"db_id"}
|
||||
onLabel={"name"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"username"}
|
||||
label={"Username"}
|
||||
type={"text"}
|
||||
placeholder="Username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"password"}
|
||||
label={"Password"}
|
||||
type={"text"}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"private_key_file"}
|
||||
label={"Private Key File"}
|
||||
type={"upload"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"pharaphrase"}
|
||||
label={"Paraphrase"}
|
||||
type={"text"}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Field
|
||||
fm={fm}
|
||||
name={"public_key_file"}
|
||||
label={"Public Key File"}
|
||||
type={"upload"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={id && id !== "new" ? 25 : 0}>
|
||||
<div className="flex h-full flex-grow">
|
||||
{id && id !== "new" ? (
|
||||
<Tabs defaultValue="parameter" className="w-full">
|
||||
<div className="bg-muted w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="parameter">Parameters</TabsTrigger>
|
||||
<TabsTrigger value="access">User Access</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="access">
|
||||
<UserAccessList userId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="parameter">
|
||||
<ParametersList userId={id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import RemoteDataTable from "@/components/RemoteDataTable";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from "@/components/ui/drawer";
|
||||
import UserEditClient from "./UserEditClient";
|
||||
|
||||
export default function UsersManager() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: any) => {
|
||||
const url = e?.detail?.url;
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
setEditingId(null);
|
||||
};
|
||||
window.addEventListener("app:add-section", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener("app:add-section", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<span />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add User</DrawerTitle>
|
||||
<DrawerDescription>Create or edit a user</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{/* @ts-ignore client component */}
|
||||
<UserEditClient id={editingId ?? "new"} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<RemoteDataTable
|
||||
table="users"
|
||||
idField="user_id"
|
||||
columns={[
|
||||
{ name: "username", label: "Username" },
|
||||
{ name: "tenants.name", label: "Client" },
|
||||
{ name: "banks.name", label: "Bank" },
|
||||
]}
|
||||
urlData="/d/user/:user_id"
|
||||
addSectionLabel="Add User"
|
||||
addSectionUrl="/d/user/new"
|
||||
include={{ tenants: true, banks: true }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
IconInnerShadowTop,
|
||||
IconListDetails,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { NavMain } from "@/components/nav-main";
|
||||
import { NavUser } from "@/components/nav-user";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
const navMain = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/d/dashboard",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Tenant",
|
||||
url: "/d/tenant",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "Bank",
|
||||
url: "/d/bank",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
title: "Database",
|
||||
url: "/d/database",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "/d/user",
|
||||
icon: IconUsers,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const [user, setUser] = React.useState<any>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchUser() {
|
||||
try {
|
||||
setLoading(true);
|
||||
// token should be sent via cookie
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(process.env.NEXT_PUBLIC_API_URL + "/me", {
|
||||
headers,
|
||||
});
|
||||
const json = await res.json();
|
||||
// expects { result: { user_id, username } }'
|
||||
console.log({ json });
|
||||
console.log({
|
||||
name: json?.result?.username || "-",
|
||||
email: undefined,
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
user_id: json?.result?.user_id,
|
||||
});
|
||||
setUser({
|
||||
name: json?.result?.username || "-",
|
||||
email: undefined,
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
user_id: json?.result?.user_id,
|
||||
});
|
||||
console.log(user);
|
||||
} catch (e) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<a href="#">
|
||||
<IconInnerShadowTop className="!size-5" />
|
||||
<span className="text-base font-semibold">Midsuit</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={navMain} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>{!loading && <NavUser user={user} />}</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import BaseBankForm from "@/components/banks/BaseBankForm";
|
||||
|
||||
export default function BankEditClient({ id }: { id: string }) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<BaseBankForm id={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import BaseForm from "@/components/BaseForm";
|
||||
import { Field, FieldLabel, FieldContent } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import db from "@/lib/dbClient";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
import AsyncSelectPaginate from "@/components/AsyncSelectPaginate";
|
||||
|
||||
function BankFields() {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => <Input {...field} />}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Code</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
name="code"
|
||||
control={control}
|
||||
render={({ field }) => <Input {...field} />}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Parent Bank</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
name="parent_bank"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AsyncSelectPaginate
|
||||
value={field.value}
|
||||
getOptionLabel={(o: any) => o.label}
|
||||
getOptionValue={(o: any) => o.value}
|
||||
loader={async (search: string, page: number) => {
|
||||
const take = 20;
|
||||
const skip = (page - 1) * take;
|
||||
|
||||
// Query tenants (paginated) from backend via db client
|
||||
const res: any = await db.tenants.findMany({
|
||||
skip,
|
||||
take,
|
||||
where: { name: { contains: search } },
|
||||
});
|
||||
|
||||
let items: any[] = [];
|
||||
if (Array.isArray(res)) items = res;
|
||||
else if (res && Array.isArray(res.rows)) items = res.rows;
|
||||
|
||||
const options = items.map((it: any) => ({
|
||||
label: it.name,
|
||||
value: String(it.tenant_id || it.id || it.bank_id),
|
||||
}));
|
||||
|
||||
const hasMore = items.length === take;
|
||||
|
||||
return { options, hasMore };
|
||||
}}
|
||||
onChange={(v: any) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BaseBankForm({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
const [defaults, setDefaults] = React.useState<any>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
if (id && id !== "new") {
|
||||
try {
|
||||
const resp = await db.banks.findFirst({ where: { bank_id: id } });
|
||||
if (mounted) setDefaults(resp || {});
|
||||
} catch (e) {
|
||||
console.error("failed to load bank", e);
|
||||
if (mounted) setDefaults({});
|
||||
}
|
||||
} else {
|
||||
if (mounted) setDefaults({});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
if (id === "new") await db.banks.create({ data: values });
|
||||
else await db.banks.update({ where: { bank_id: id }, data: values });
|
||||
|
||||
router.push("/d/bank");
|
||||
};
|
||||
|
||||
if (id && id !== "new" && defaults === undefined)
|
||||
return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<BaseForm
|
||||
defaultValues={defaults}
|
||||
onSubmit={onSubmit}
|
||||
submitLabel={id === "new" ? "Create" : "Save"}
|
||||
>
|
||||
<BankFields />
|
||||
</BaseForm>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
|
||||
export const description = "An interactive area chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
visitors: {
|
||||
label: "Visitors",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date("2024-06-30")
|
||||
let daysToSubtract = 90
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Visitors</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Total for the last 3 months
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Last 3 months</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-desktop)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-mobile)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="url(#fillMobile)"
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="url(#fillDesktop)"
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,903 @@
|
|||
"use client";
|
||||
import * as React from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconPencil,
|
||||
IconGripVertical,
|
||||
IconLayoutColumns,
|
||||
IconPlus,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
import { z } from "zod";
|
||||
// lightweight safe get to avoid adding types for lodash/get
|
||||
function safeGet(obj: any, path: string, defaultValue?: any) {
|
||||
if (!obj || !path) return defaultValue;
|
||||
const parts = path.split(".");
|
||||
let cur = obj as any;
|
||||
for (const p of parts) {
|
||||
if (cur == null) return defaultValue;
|
||||
cur = cur[p];
|
||||
}
|
||||
return cur === undefined ? defaultValue : cur;
|
||||
}
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ButtonLink } from "./ui/button-link";
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
});
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
function DragHandle({ id }: { id: number }) {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent"
|
||||
>
|
||||
<IconGripVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
{
|
||||
id: "drag",
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
||||
<DropdownMenuItem>Favorite</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-dragging={isDragging}
|
||||
ref={setNodeRef}
|
||||
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsCell({
|
||||
row,
|
||||
urlData,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
row: any;
|
||||
urlData?: ((d: any) => string) | string;
|
||||
onDelete?: (id: any) => Promise<void>;
|
||||
onEdit?: (row: any) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const resolveUrlFromPattern = (pattern: string, data: any) => {
|
||||
// support :field and {field} placeholders
|
||||
let url = pattern.replace(/:([a-zA-Z0-9_\.]+)/g, (_, p) => {
|
||||
return encodeURIComponent(String(safeGet(data, p, "")));
|
||||
});
|
||||
url = url.replace(/\{([a-zA-Z0-9_\.]+)\}/g, (_, p) => {
|
||||
return encodeURIComponent(String(safeGet(data, p, "")));
|
||||
});
|
||||
return url;
|
||||
};
|
||||
|
||||
const goTo = () => {
|
||||
if (!urlData) return;
|
||||
let url: string | undefined;
|
||||
if (typeof urlData === "function") url = urlData(row.original);
|
||||
else if (typeof urlData === "string") {
|
||||
// if pattern contains placeholder, resolve it; otherwise append id
|
||||
const hasPlaceholder = /[:{]/.test(urlData);
|
||||
if (hasPlaceholder) url = resolveUrlFromPattern(urlData, row.original);
|
||||
else
|
||||
url = urlData.endsWith("/")
|
||||
? urlData + row.original.id
|
||||
: `${urlData}/${row.original.id}`;
|
||||
}
|
||||
if (url) router.push(url);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const ok = confirm("Are you sure you want to delete this item?");
|
||||
if (!ok) return;
|
||||
if (!onDelete) return;
|
||||
try {
|
||||
await onDelete(row.original.id);
|
||||
toast.success("Deleted");
|
||||
} catch (e: any) {
|
||||
console.error("delete failed", e);
|
||||
toast.error("Delete failed: " + (e?.message || String(e)));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => (onEdit ? onEdit(row.original) : goTo())}
|
||||
title="Edit"
|
||||
>
|
||||
<IconPencil />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
{/* <DropdownMenu >
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem onClick={goTo}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
||||
<DropdownMenuItem>Favorite</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data: initialData,
|
||||
columns: inputColumns,
|
||||
urlData,
|
||||
onDelete,
|
||||
addSectionLabel,
|
||||
addSectionUrl,
|
||||
onAdd,
|
||||
onEdit,
|
||||
}: {
|
||||
data: any[];
|
||||
columns?: any[]; // accept either TanStack ColumnDef[] or simple spec [{value,label,cell?}]
|
||||
urlData?: ((data: any) => string) | string; // function to convert row data to URL
|
||||
onDelete?: (id: any) => Promise<void>;
|
||||
addSectionLabel?: string;
|
||||
addSectionUrl?: string;
|
||||
onAdd?: () => void;
|
||||
onEdit?: (row: any) => void;
|
||||
}) {
|
||||
const [data, setData] = React.useState(() => initialData);
|
||||
// Keep internal state in sync when parent passes new data (e.g., after fetch)
|
||||
React.useEffect(() => {
|
||||
setData(initialData || []);
|
||||
}, [initialData]);
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const sortableId = React.useId();
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
||||
() => data?.map(({ id }) => id) || [],
|
||||
[data]
|
||||
);
|
||||
|
||||
// convert inputColumns (simple spec) into TanStack ColumnDef[] if provided
|
||||
const convertedUserColumns: ColumnDef<any, any>[] = (inputColumns || [])
|
||||
.filter(Boolean)
|
||||
.map((c: any) => {
|
||||
const value = c.value || c.name;
|
||||
const header = c.label || value;
|
||||
if (typeof c.cell === "function") {
|
||||
return {
|
||||
id: value,
|
||||
header,
|
||||
cell: c.cell,
|
||||
accessorFn: (row: any) => safeGet(row, value),
|
||||
} as ColumnDef<any, any>;
|
||||
}
|
||||
return {
|
||||
id: value,
|
||||
header,
|
||||
accessorFn: (row: any) => safeGet(row, value),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="w-32">
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{String(safeGet(row.original, value) ?? "")}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
} as ColumnDef<any, any>;
|
||||
});
|
||||
|
||||
// if no inputColumns provided, derive from first data row
|
||||
const derivedColumns: ColumnDef<any, any>[] = React.useMemo(() => {
|
||||
if (inputColumns && inputColumns.length) return [];
|
||||
const sample =
|
||||
(data && data.length ? data[0] : initialData && initialData[0]) || {};
|
||||
const keys = Object.keys(sample || {});
|
||||
const humanize = (s: string) =>
|
||||
s
|
||||
.replace(/[_\.\-]+/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.split(" ")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
|
||||
return keys.map((k) => {
|
||||
return {
|
||||
id: k,
|
||||
header: humanize(k),
|
||||
accessorFn: (row: any) => safeGet(row, k),
|
||||
cell: ({ row }: any) => (
|
||||
<div className="truncate">
|
||||
{String(safeGet(row.original, k) ?? "")}
|
||||
</div>
|
||||
),
|
||||
} as ColumnDef<any, any>;
|
||||
});
|
||||
}, [inputColumns, data, initialData]);
|
||||
|
||||
// build final columns: keep drag + select, insert converted user columns, then rest from static columns after the default header/type
|
||||
const finalColumns: ColumnDef<any, any>[] = React.useMemo(() => {
|
||||
const prefix = columns.slice(0, 2);
|
||||
const suffix = columns.slice(2); // drop header (2) and type (3)
|
||||
if (convertedUserColumns.length > 0)
|
||||
return [...(prefix as any), ...convertedUserColumns, ...(suffix as any)];
|
||||
if (derivedColumns.length > 0)
|
||||
return [...(prefix as any), ...derivedColumns, ...(suffix as any)];
|
||||
return columns as ColumnDef<any, any>[];
|
||||
}, [convertedUserColumns, derivedColumns]);
|
||||
|
||||
// Inject actions cell that can use the urlData prop to navigate to row-specific pages
|
||||
const columnsWithActions = React.useMemo(() => {
|
||||
return finalColumns.map((col) => {
|
||||
if (col.id === "actions") {
|
||||
return {
|
||||
...col,
|
||||
cell: ({ row }: any) => (
|
||||
<ActionsCell row={row} urlData={urlData} onDelete={onDelete} onEdit={onEdit} />
|
||||
),
|
||||
} as ColumnDef<any, any>;
|
||||
}
|
||||
return col;
|
||||
});
|
||||
}, [finalColumns, urlData, onDelete, onEdit]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id);
|
||||
const newIndex = dataIds.indexOf(over.id);
|
||||
return arrayMove(data, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue="outline"
|
||||
className="w-full flex-col justify-start gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<Label htmlFor="view-selector" className="sr-only">
|
||||
View
|
||||
</Label>
|
||||
<Select defaultValue="outline">
|
||||
<SelectTrigger
|
||||
className="flex w-fit @4xl/main:hidden"
|
||||
size="sm"
|
||||
id="view-selector"
|
||||
>
|
||||
<SelectValue placeholder="Select a view" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="past-performance">Past Performance</SelectItem>
|
||||
<SelectItem value="key-personnel">Key Personnel</SelectItem>
|
||||
<SelectItem value="focus-documents">Focus Documents</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TabsList
|
||||
style={{
|
||||
display: "none",
|
||||
}}
|
||||
className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex"
|
||||
>
|
||||
<TabsTrigger value="outline">Outline</TabsTrigger>
|
||||
<TabsTrigger value="past-performance">
|
||||
Past Performance <Badge variant="secondary">3</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key-personnel">
|
||||
Key Personnel <Badge variant="secondary">2</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Customize Columns</span>
|
||||
<span className="lg:hidden">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onAdd ? (
|
||||
<Button size="sm" onClick={onAdd}>
|
||||
<IconPlus />
|
||||
<span className="hidden lg:inline">{addSectionLabel ?? "Add Section"}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<ButtonLink
|
||||
href={addSectionUrl || "#"}
|
||||
disabled={!addSectionUrl}
|
||||
size="sm"
|
||||
>
|
||||
<IconPlus />
|
||||
<span className="hidden lg:inline">
|
||||
{addSectionLabel ?? "Add Section"}
|
||||
</span>{" "}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="outline"
|
||||
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
id={sortableId}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="**:data-[slot=table-cell]:first:w-8">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<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-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<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>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="past-performance"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="focus-documents"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
{ month: "January", desktop: 186, mobile: 80 },
|
||||
{ month: "February", desktop: 305, mobile: 200 },
|
||||
{ month: "March", desktop: 237, mobile: 120 },
|
||||
{ month: "April", desktop: 73, mobile: 190 },
|
||||
{ month: "May", desktop: 209, mobile: 130 },
|
||||
{ month: "June", desktop: 214, mobile: 140 },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? "bottom" : "right"}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||
{item.header}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<DrawerTitle>{item.header}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Showing total visitors for the last 6 months
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
||||
{!isMobile && (
|
||||
<>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 10,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
hide
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="var(--color-mobile)"
|
||||
fillOpacity={0.6}
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="var(--color-desktop)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month{" "}
|
||||
<IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Showing total visitors for the last 6 months. This is just
|
||||
some random text to test the layout. It spans multiple lines
|
||||
and should wrap around.
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<form className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="header">Header</Label>
|
||||
<Input id="header" defaultValue={item.header} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select defaultValue={item.type}>
|
||||
<SelectTrigger id="type" className="w-full">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Table of Contents">
|
||||
Table of Contents
|
||||
</SelectItem>
|
||||
<SelectItem value="Executive Summary">
|
||||
Executive Summary
|
||||
</SelectItem>
|
||||
<SelectItem value="Technical Approach">
|
||||
Technical Approach
|
||||
</SelectItem>
|
||||
<SelectItem value="Design">Design</SelectItem>
|
||||
<SelectItem value="Capabilities">Capabilities</SelectItem>
|
||||
<SelectItem value="Focus Documents">
|
||||
Focus Documents
|
||||
</SelectItem>
|
||||
<SelectItem value="Narrative">Narrative</SelectItem>
|
||||
<SelectItem value="Cover Page">Cover Page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select defaultValue={item.status}>
|
||||
<SelectTrigger id="status" className="w-full">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Done">Done</SelectItem>
|
||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="target">Target</Label>
|
||||
<Input id="target" defaultValue={item.target} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="limit">Limit</Label>
|
||||
<Input id="limit" defaultValue={item.limit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="reviewer">Reviewer</Label>
|
||||
<Select defaultValue={item.reviewer}>
|
||||
<SelectTrigger id="reviewer" className="w-full">
|
||||
<SelectValue placeholder="Select a reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,538 @@
|
|||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FieldCheckbox } from "./field/TypeCheckbox";
|
||||
import { TypeDropdown } from "./field/TypeDropdown";
|
||||
import { TypeInput } from "./field/TypeInput";
|
||||
import { TypeUpload } from "./field/TypeUpload";
|
||||
import { TypeTag } from "./field/TypeTag";
|
||||
import get from "lodash.get";
|
||||
import { getNumber } from "@/lib/utils/getNumber";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { FieldRadio } from "./field/TypeRadio";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TooltipBetter } from "../ui/tooltip-better";
|
||||
import { TypeDropdownBetter } from "./field/TypeDropdownBetter";
|
||||
import { TypeAsyncDropdown } from "./field/TypeAsyncDropdown";
|
||||
export interface FieldProps {
|
||||
fm: any;
|
||||
label?: string;
|
||||
name: string;
|
||||
isBetter?: boolean;
|
||||
tooltip?: string;
|
||||
valueKey?: string;
|
||||
target?: string;
|
||||
onLoad?: (params?: any) => Promise<any> | any;
|
||||
onCount?: (param?: any) => Promise<any> | any;
|
||||
onDelete?: (item: any) => Promise<any> | any;
|
||||
urlUpload?: string;
|
||||
type?:
|
||||
| "rating"
|
||||
| "color"
|
||||
| "single-checkbox"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "upload"
|
||||
| "multi-upload"
|
||||
| "dropdown"
|
||||
| "multi-dropdown"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "single-checkbox"
|
||||
| "richtext"
|
||||
| "tag"
|
||||
| "text"
|
||||
| "money"
|
||||
| "textarea"
|
||||
| "time"
|
||||
| "date"
|
||||
| "password"
|
||||
| "email"
|
||||
| "multi-dropdown-better"
|
||||
| "multi-async"
|
||||
| "dropdown-async"
|
||||
| "status";
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
hidden_label?: boolean;
|
||||
onChange?: ({ data }: any) => Promise<void> | void;
|
||||
className?: string;
|
||||
classField?: string;
|
||||
style?: string;
|
||||
prefix?: string | any | (() => any);
|
||||
suffix?: string | any | (() => any);
|
||||
allowNew?: boolean;
|
||||
unique?: boolean;
|
||||
onLabel?: string | ((item: any) => any);
|
||||
onValue?: string | ((item: any) => any);
|
||||
pagination?: boolean;
|
||||
search?: "api" | "local";
|
||||
visibleLabel?: boolean;
|
||||
autoRefresh?: boolean;
|
||||
forceDisabled?: boolean;
|
||||
description?: string | (() => any);
|
||||
styleField?: string | null;
|
||||
isDebounce?: boolean;
|
||||
data?: any;
|
||||
mode?: string;
|
||||
valueChecked?: string[];
|
||||
}
|
||||
export const Field: React.FC<FieldProps> = ({
|
||||
fm,
|
||||
visibleLabel = false,
|
||||
label,
|
||||
isBetter = false,
|
||||
name,
|
||||
onLoad,
|
||||
type = "text",
|
||||
placeholder,
|
||||
required,
|
||||
disabled,
|
||||
hidden_label,
|
||||
onChange,
|
||||
className,
|
||||
classField,
|
||||
style,
|
||||
prefix,
|
||||
suffix,
|
||||
allowNew,
|
||||
unique = true,
|
||||
tooltip,
|
||||
valueKey,
|
||||
onDelete,
|
||||
onCount,
|
||||
onLabel,
|
||||
onValue = "id",
|
||||
target,
|
||||
pagination = true,
|
||||
search = "api",
|
||||
autoRefresh = false,
|
||||
forceDisabled,
|
||||
description,
|
||||
styleField,
|
||||
isDebounce = false,
|
||||
valueChecked,
|
||||
urlUpload,
|
||||
mode,
|
||||
}) => {
|
||||
let result = null;
|
||||
const field = useLocal({
|
||||
focus: false,
|
||||
});
|
||||
const suffixRef = useRef<HTMLDivElement | null>(null);
|
||||
const prefixRef = useRef<HTMLDivElement | null>(null);
|
||||
const initField = {
|
||||
label,
|
||||
isBetter,
|
||||
name,
|
||||
onLoad,
|
||||
type,
|
||||
placeholder,
|
||||
required,
|
||||
disabled,
|
||||
hidden_label,
|
||||
onChange,
|
||||
className,
|
||||
classField,
|
||||
style,
|
||||
prefix,
|
||||
suffix,
|
||||
allowNew,
|
||||
unique,
|
||||
tooltip,
|
||||
valueKey,
|
||||
onDelete,
|
||||
onCount,
|
||||
onLabel,
|
||||
onValue,
|
||||
target,
|
||||
pagination,
|
||||
search,
|
||||
};
|
||||
const is_disable =
|
||||
typeof forceDisabled === "boolean"
|
||||
? forceDisabled
|
||||
: fm.mode === "view"
|
||||
? true
|
||||
: disabled;
|
||||
const error = fm.error?.[name];
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (typeof fm.fields?.[name] !== "object") {
|
||||
const fields = fm.fields?.[name];
|
||||
fm.fields[name] = {
|
||||
...fields,
|
||||
label,
|
||||
name,
|
||||
onLoad,
|
||||
type,
|
||||
placeholder,
|
||||
required,
|
||||
disabled,
|
||||
hidden_label,
|
||||
onChange,
|
||||
className,
|
||||
style,
|
||||
};
|
||||
fm.render();
|
||||
}
|
||||
}, 1000);
|
||||
}, []);
|
||||
const before = typeof prefix === "function" ? prefix() : prefix;
|
||||
const after = typeof suffix === "function" ? suffix() : suffix;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
"flex",
|
||||
style === "inline" ? "flex-row gap-x-1" : "flex-col",
|
||||
css`
|
||||
.field input:focus {
|
||||
outline: 0px !important;
|
||||
border: 0px !important;
|
||||
outline-offset: 0px !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
}
|
||||
.field textarea:focus {
|
||||
outline: 0px !important;
|
||||
border: 0px !important;
|
||||
outline-offset: 0px !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
}
|
||||
.field input {
|
||||
border: 0px !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
`,
|
||||
style === "gform" &&
|
||||
css`
|
||||
.field input {
|
||||
padding-left: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
.field textarea {
|
||||
padding-left: 0px !important;
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
{!hidden_label ? (
|
||||
<label
|
||||
className={cx(
|
||||
"block mb-2 text-md font-medium text-sm flex flex-row",
|
||||
style === "inline" ? "w-[100px]" : "",
|
||||
visibleLabel ? "text-transparent" : "text-gray-900"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required ? (
|
||||
<span className="flex flex-row px-0.5 text-red-500">*</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</label>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<TooltipBetter content={tooltip} side="bottom">
|
||||
<div
|
||||
className={cn(
|
||||
is_disable
|
||||
? "border border-gray-100 bg-gray-100 is_disable"
|
||||
: "border border-gray-300 ",
|
||||
"relative field",
|
||||
!is_disable
|
||||
? style === "underline" || style === "gform"
|
||||
? "focus-within:border-b focus-within:border-b-primary"
|
||||
: "focus-within:border focus-within:border-primary"
|
||||
: "",
|
||||
style === "underline" || style === "gform"
|
||||
? "rounded-none border-transparent border-b-gray-300 "
|
||||
: "",
|
||||
[
|
||||
"rating",
|
||||
"color",
|
||||
"single-checkbox",
|
||||
"radio",
|
||||
"checkbox",
|
||||
"multi-upload",
|
||||
].includes(type) &&
|
||||
css`
|
||||
border: 0px !important;
|
||||
`,
|
||||
["upload"].includes(type) &&
|
||||
css`
|
||||
padding: 0px !important;
|
||||
`,
|
||||
is_disable &&
|
||||
["multi-upload"].includes(type) &&
|
||||
css`
|
||||
background: transparent !important;
|
||||
`,
|
||||
classField,
|
||||
error
|
||||
? "flex flex-row rounded-md flex-grow border-red-500 border items-center"
|
||||
: style === "underline"
|
||||
? "flex flex-row rounded-none flex-grow items-center"
|
||||
: "flex flex-row rounded-md flex-grow items-center"
|
||||
)}
|
||||
>
|
||||
{before && (
|
||||
<div
|
||||
// ref={prefixRef}
|
||||
className={cx(
|
||||
"px-1 py-1 items-center flex flex-row flex-grow rounded-l-md h-full prefix",
|
||||
css`
|
||||
height: 2.13rem;
|
||||
`
|
||||
// style === "gform"
|
||||
// ? ""
|
||||
// : is_disable
|
||||
// ? "bg-gray-200/50 "
|
||||
// : "bg-gray-200/50 "
|
||||
)}
|
||||
>
|
||||
{before}
|
||||
</div>
|
||||
)}
|
||||
{/* "multi-dropdown-better" */}
|
||||
{["upload"].includes(type) ? (
|
||||
<>
|
||||
<TypeUpload
|
||||
fm={fm}
|
||||
name={name}
|
||||
on_change={onChange}
|
||||
mode={"upload"}
|
||||
urlUpload={urlUpload}
|
||||
disabled={is_disable}
|
||||
/>
|
||||
</>
|
||||
) : ["multi-upload"].includes(type) ? (
|
||||
<>
|
||||
<TypeUpload
|
||||
fm={fm}
|
||||
name={name}
|
||||
on_change={onChange}
|
||||
mode={"upload"}
|
||||
type="multi"
|
||||
disabled={is_disable}
|
||||
valueKey={valueKey}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</>
|
||||
) : ["dropdown"].includes(type) ? (
|
||||
<>
|
||||
<TypeDropdown
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
required={required}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
allowNew={allowNew}
|
||||
/>
|
||||
</>
|
||||
) : ["multi-dropdown"].includes(type) ? (
|
||||
<>
|
||||
<TypeDropdown
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
required={required}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
mode="multi"
|
||||
unique={unique}
|
||||
isBetter={isBetter}
|
||||
/>
|
||||
</>
|
||||
) : ["multi-async"].includes(type) ? (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<TypeAsyncDropdown
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
label={label}
|
||||
target={target}
|
||||
required={required}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
onLabel={onLabel}
|
||||
onValue={onValue}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
mode="multi"
|
||||
pagination={pagination}
|
||||
unique={unique}
|
||||
isBetter={isBetter}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : ["dropdown-async"].includes(type) ? (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<TypeAsyncDropdown
|
||||
label={label}
|
||||
fm={fm}
|
||||
autoRefresh={autoRefresh}
|
||||
fields={initField}
|
||||
required={required}
|
||||
name={name}
|
||||
target={target}
|
||||
onLoad={onLoad}
|
||||
onLabel={onLabel}
|
||||
onValue={onValue}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
pagination={pagination}
|
||||
unique={unique}
|
||||
isBetter={isBetter}
|
||||
search={search}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : ["multi-dropdown-better"].includes(type) ? (
|
||||
<>
|
||||
<TypeDropdownBetter
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
required={required}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
mode="multi"
|
||||
unique={unique}
|
||||
isBetter={isBetter}
|
||||
onCount={onCount}
|
||||
onLabel={onLabel}
|
||||
/>
|
||||
</>
|
||||
) : ["checkbox"].includes(type) ? (
|
||||
<>
|
||||
<FieldCheckbox
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
</>
|
||||
) : ["radio"].includes(type) ? (
|
||||
<>
|
||||
<FieldRadio
|
||||
fields={initField}
|
||||
fm={fm}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
</>
|
||||
) : ["single-checkbox"].includes(type) ? (
|
||||
<>
|
||||
<FieldCheckbox
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
name={name}
|
||||
onLoad={onLoad}
|
||||
placeholder={placeholder}
|
||||
disabled={is_disable}
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
mode="single"
|
||||
/>
|
||||
</>
|
||||
) : ["tag"].includes(type) ? (
|
||||
<>
|
||||
<TypeTag
|
||||
styleField={styleField}
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
name={name}
|
||||
valueChecked={valueChecked}
|
||||
disabled={is_disable}
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
mode={mode}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TypeInput
|
||||
fm={fm}
|
||||
fields={initField}
|
||||
name={name}
|
||||
isDebounce={isDebounce}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
type={type}
|
||||
disabled={is_disable}
|
||||
onChange={onChange}
|
||||
onFocus={() => {
|
||||
field.focus = true;
|
||||
field.render();
|
||||
}}
|
||||
className={cx(
|
||||
before &&
|
||||
css`
|
||||
padding-left: ${getNumber(
|
||||
get(prefixRef, "current.clientWidth")
|
||||
) + 10}px;
|
||||
`,
|
||||
after &&
|
||||
css`
|
||||
padding-right: ${getNumber(
|
||||
get(suffixRef, "current.clientWidth")
|
||||
) + 10}px;
|
||||
`,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{after && (
|
||||
<div
|
||||
// ref={suffixRef}
|
||||
className={cx(
|
||||
"px-1 py-1 items-center flex flex-row flex-grow rounded-r-md h-full suffix",
|
||||
css`
|
||||
height: 2.13rem;
|
||||
`,
|
||||
is_disable ? "bg-gray-200/50 " : "bg-gray-200/50 "
|
||||
)}
|
||||
>
|
||||
{after}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipBetter>
|
||||
{description ? (
|
||||
<div className="text-xs text-gray-500 py-1">
|
||||
{typeof description === "function" ? description() : description}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500 py-1">{error}</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
"use client";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { AlertTriangle, Check, Loader2 } from "lucide-react";
|
||||
import { css } from "@emotion/css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "../ui/resize";
|
||||
import get from "lodash.get";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { normalDate } from "@/lib/utils/date";
|
||||
|
||||
// ensure cx is available (some files rely on global `cx` at runtime)
|
||||
const cx = (globalThis as any).cx || cn;
|
||||
|
||||
type Local<T> = {
|
||||
data: T | null;
|
||||
submit: () => Promise<void>;
|
||||
render: () => void;
|
||||
};
|
||||
|
||||
export const Form: React.FC<any> = ({
|
||||
children,
|
||||
header,
|
||||
onLoad,
|
||||
onSubmit,
|
||||
onFooter,
|
||||
showResize,
|
||||
mode,
|
||||
className,
|
||||
onInit,
|
||||
afterLoad,
|
||||
toastMessage,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
ready: false,
|
||||
data: null as any | null,
|
||||
btn_ready: true,
|
||||
submit: async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{toastMessage ? `${toastMessage}...` : "Saving..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
local.btn_ready = false;
|
||||
local.render();
|
||||
try {
|
||||
const fieldDate: any = local?.fields;
|
||||
let isError = false;
|
||||
let error: Record<string, string> = {};
|
||||
try {
|
||||
const dateFields = Object.values(fieldDate).filter(
|
||||
(field: any) => get(field, "type") === "date"
|
||||
);
|
||||
if (dateFields.length) {
|
||||
dateFields.forEach((e: any) => {
|
||||
if (e?.name) {
|
||||
local.data[e.name] = normalDate(local.data[e.name]);
|
||||
}
|
||||
});
|
||||
local.render();
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error("Error processing date fields:", ex);
|
||||
}
|
||||
|
||||
if (mode !== "view") {
|
||||
const fieldRequired = Object.values(fieldDate).filter(
|
||||
(field: any) => field?.required || field?.type === "table"
|
||||
);
|
||||
|
||||
if (fieldRequired.length) {
|
||||
fieldRequired.forEach((e: any) => {
|
||||
let keys = e?.name;
|
||||
const type = e?.type;
|
||||
|
||||
if (type === "table" && e?.fields?.length) {
|
||||
e.fields.forEach((item: any, index: number) => {
|
||||
let errorChilds: Record<string, string> = {};
|
||||
const fieldRequired = Object.values(item?.fields).filter(
|
||||
(field: any) => field?.required
|
||||
);
|
||||
fieldRequired.forEach((subField: any) => {
|
||||
let keySub = subField?.name;
|
||||
const typeSub = subField?.type;
|
||||
const val = get(local.data, `${keys}[${index}].${keySub}`);
|
||||
if (["dropdown-async", "multi-async"].includes(typeSub)) {
|
||||
keySub = subField?.target || subField?.name;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"multi-dropdown",
|
||||
"checkbox",
|
||||
"multi-upload",
|
||||
"multi-async",
|
||||
].includes(typeSub)
|
||||
) {
|
||||
if (
|
||||
!Array.isArray(get(local.data, keys)) ||
|
||||
!val?.length
|
||||
) {
|
||||
errorChilds[subField.name] =
|
||||
"This field requires at least one item.";
|
||||
isError = true;
|
||||
}
|
||||
} else if (!val) {
|
||||
errorChilds[subField.name] =
|
||||
"Please fill out this field.";
|
||||
isError = true;
|
||||
}
|
||||
|
||||
console.log({
|
||||
keySub,
|
||||
data: get(local.data, `${keys}[${index}]`),
|
||||
val,
|
||||
});
|
||||
});
|
||||
|
||||
item.error = errorChilds;
|
||||
});
|
||||
} else {
|
||||
if (["dropdown-async", "multi-async"].includes(type)) {
|
||||
keys = e?.target || e?.name;
|
||||
}
|
||||
const val = get(local.data, keys);
|
||||
if (
|
||||
[
|
||||
"multi-dropdown",
|
||||
"checkbox",
|
||||
"multi-upload",
|
||||
"multi-async",
|
||||
].includes(type)
|
||||
) {
|
||||
if (!Array.isArray(val) || !val?.length) {
|
||||
error[e.name] = "This field requires at least one item.";
|
||||
isError = true;
|
||||
}
|
||||
} else if (!val) {
|
||||
error[e.name] = "Please fill out this field.";
|
||||
isError = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
local.error = error;
|
||||
local.render();
|
||||
if (isError) {
|
||||
throw new Error("please check your input field.");
|
||||
} else {
|
||||
await onSubmit(local);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
setTimeout(() => {
|
||||
toast.success(
|
||||
<div
|
||||
className={cx(
|
||||
"cursor-pointer flex flex-col select-none items-stretch flex-1 w-full"
|
||||
)}
|
||||
onClick={() => {
|
||||
toast.dismiss();
|
||||
}}
|
||||
>
|
||||
<div className="flex text-green-700 items-center success-title font-semibold">
|
||||
<Check className="h-6 w-6 mr-1 " />
|
||||
|
||||
{toastMessage ? `${toastMessage} success` : "Record Saved"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, 100);
|
||||
}, 100);
|
||||
} catch (ex: any) {
|
||||
const msg = get(ex, "response.data.meta.message") || ex.message;
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
setTimeout(() => {
|
||||
toast.error(
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex text-red-600 items-center">
|
||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
||||
{toastMessage
|
||||
? `${toastMessage} failed ${msg}.`
|
||||
: `Submit Failed ${msg}.`}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
dismissible: true,
|
||||
className: css`
|
||||
background: #ffecec;
|
||||
border: 2px solid red;
|
||||
`,
|
||||
}
|
||||
);
|
||||
}, 100);
|
||||
}, 100);
|
||||
}
|
||||
local.btn_ready = true;
|
||||
local.render();
|
||||
},
|
||||
reload: async () => {
|
||||
local.ready = false;
|
||||
local.render();
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
local.data = null;
|
||||
local.render();
|
||||
try {
|
||||
const res = await onLoad();
|
||||
local.data = res;
|
||||
} catch (ex) {}
|
||||
local.ready = true;
|
||||
local.render();
|
||||
if (typeof afterLoad === "function") {
|
||||
afterLoad(local);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
},
|
||||
fields: {} as any,
|
||||
render: () => {},
|
||||
error: {} as any,
|
||||
onChange: () => {},
|
||||
mode,
|
||||
});
|
||||
useEffect(() => {
|
||||
local.onChange();
|
||||
}, [local.data]);
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
if (typeof onInit === "function") {
|
||||
onInit(local);
|
||||
}
|
||||
local.ready = false;
|
||||
local.render();
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
let res = null as any;
|
||||
try {
|
||||
res = await onLoad();
|
||||
local.data = res;
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
} catch (ex: any) {
|
||||
const msg = get(ex, "response.data.meta.message") || ex.message;
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
setTimeout(() => {
|
||||
toast.error(
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex text-red-600 items-center">
|
||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
||||
{`Failed ${msg}.`}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
dismissible: true,
|
||||
className: css`
|
||||
background: #ffecec;
|
||||
border: 2px solid red;
|
||||
`,
|
||||
}
|
||||
);
|
||||
}, 100);
|
||||
}, 100);
|
||||
}
|
||||
local.ready = true;
|
||||
local.render();
|
||||
if (typeof afterLoad === "function") {
|
||||
await afterLoad(local);
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
// Tambahkan dependency ke header agar reaktif
|
||||
const HeaderComponent = typeof header === "function" ? header(local) : <></>;
|
||||
if (!local.ready)
|
||||
return (
|
||||
<div className="flex flex-grow flex-row items-center justify-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-[250px]" />
|
||||
<Skeleton className="h-16 w-[250px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={`flex-grow flex-col flex h-full ${className}`}>
|
||||
<div className="flex flex-row">{HeaderComponent}</div>
|
||||
{showResize ? (
|
||||
// Resize panels...
|
||||
<ResizablePanelGroup direction="vertical" className="rounded-lg border">
|
||||
<ResizablePanel className="border-none flex flex-col">
|
||||
<form
|
||||
className="flex flex-grow flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
local.submit();
|
||||
}}
|
||||
>
|
||||
{local.ready ? (
|
||||
children(local)
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-[250px]" />
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="border-none" />
|
||||
<ResizablePanel className="border-t-2 flex flex-row flex-grow">
|
||||
{typeof onFooter === "function" ? onFooter(local) : null}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<>
|
||||
<form
|
||||
className={cx(
|
||||
"flex flex-col ",
|
||||
typeof onFooter === "function" ? "" : "flex-grow"
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
local.submit();
|
||||
}}
|
||||
>
|
||||
{local.ready ? (
|
||||
children(local)
|
||||
) : (
|
||||
<div className="flex flex-grow flex-row items-center justify-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-[250px]" />
|
||||
<Skeleton className="h-16 w-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
{typeof onFooter === "function" ? (
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-grow flex-col",
|
||||
css`
|
||||
.tbl {
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
{onFooter(local)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { FC, useEffect } from "react";
|
||||
|
||||
export const InitEditor: FC<any> = ({ local, editor }) => {
|
||||
useEffect(() => {
|
||||
local.editor = editor;
|
||||
local.render();
|
||||
}, []);
|
||||
return <div></div>;
|
||||
};
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
import { siteurl } from "@/lib/utils/siteurl";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
export const ThumbPreview = ({
|
||||
url,
|
||||
options,
|
||||
}: {
|
||||
url: string;
|
||||
options: ReactElement;
|
||||
}) => {
|
||||
const local = useLocal({ size: "", is_doc: true }, async () => {});
|
||||
|
||||
const file = getFileName(url);
|
||||
if (typeof file === "string") return;
|
||||
|
||||
const color = darkenColor(generateRandomColor(file.extension));
|
||||
let content = (
|
||||
<div
|
||||
className={cx(
|
||||
css`
|
||||
background: white;
|
||||
color: ${color};
|
||||
border: 1px solid ${color};
|
||||
color: ${color};
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
font-weight: black;
|
||||
padding: 3px 7px;
|
||||
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #1c4ed8;
|
||||
outline: 1px solid #1c4ed8;
|
||||
}
|
||||
`,
|
||||
"flex justify-center items-center flex-col"
|
||||
)}
|
||||
onClick={() => {
|
||||
// let _url = siteurl(url || "");
|
||||
// window.open(_url, "_blank");
|
||||
}}
|
||||
>
|
||||
<div>{file.extension}</div>
|
||||
<div
|
||||
className={css`
|
||||
font-size: 9px;
|
||||
color: gray;
|
||||
margin-top: -3px;
|
||||
`}
|
||||
>
|
||||
{local.size}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
let is_image = false;
|
||||
|
||||
if ([".png", ".jpeg", ".jpg", ".webp"].find((e) => url.endsWith(e))) {
|
||||
is_image = true;
|
||||
local.is_doc = false;
|
||||
content = (
|
||||
<div className="rounded-lg w-96 overflow-hidden shadow-lg">
|
||||
<img
|
||||
onClick={() => {
|
||||
let _url = siteurl(url || "");
|
||||
window.open(_url, "_blank");
|
||||
}}
|
||||
className={cx(
|
||||
"rounded-md w-96 h-full object-cover",
|
||||
css`
|
||||
&:hover {
|
||||
outline: 2px solid #1c4ed8;
|
||||
}
|
||||
`,
|
||||
css`
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
#ccc 25%,
|
||||
transparent 25%
|
||||
),
|
||||
linear-gradient(135deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(135deg, transparent 75%, #ccc 75%);
|
||||
background-size: 25px 25px; /* Must be a square */
|
||||
background-position: 0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
|
||||
`
|
||||
)}
|
||||
src={siteurl(
|
||||
`/_img/${url.substring("_file/".length)}?${"w=60&h=60&fit=cover"}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.extension && (
|
||||
<div
|
||||
className={cx(
|
||||
"flex border rounded items-start px-1 relative bg-white cursor-pointer",
|
||||
"space-x-1 py-1 thumb-preview"
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
{options}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilePreview = ({
|
||||
url,
|
||||
disabled,
|
||||
limit_name,
|
||||
}: {
|
||||
url: any;
|
||||
disabled?: boolean;
|
||||
limit_name?: number;
|
||||
}) => {
|
||||
let ural = url;
|
||||
if (url instanceof File) {
|
||||
ural = `${URL.createObjectURL(url)}.${url.name.split(".").pop()}`;
|
||||
}
|
||||
const file = getFileName(ural);
|
||||
if (typeof file === "string")
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
css`
|
||||
border-radius: 3px;
|
||||
padding: 0px 5px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
height: 20px;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
`,
|
||||
"flex items-center text-md"
|
||||
)}
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
);
|
||||
const color = darkenColor(generateRandomColor(file.extension));
|
||||
let content = (
|
||||
<div
|
||||
className={cx(
|
||||
css`
|
||||
background: white;
|
||||
border: 1px solid ${color};
|
||||
color: ${color};
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
padding: 0px 5px;
|
||||
font-size: 9px;
|
||||
height: 15px;
|
||||
margin-right: 5px;
|
||||
`,
|
||||
"flex items-center"
|
||||
)}
|
||||
>
|
||||
{file.extension}
|
||||
</div>
|
||||
);
|
||||
const getFileNameWithoutExtension = (filename: string) => {
|
||||
const parts = filename.split(".");
|
||||
parts.pop(); // Hapus bagian terakhir (ekstensi)
|
||||
const result = parts.join("."); // Gabungkan kembali
|
||||
return limit_name ? result.substring(0, limit_name) : result;
|
||||
};
|
||||
const ura =
|
||||
ural && ural.startsWith("blob:") ? getFileNameWithoutExtension(ural) : ural;
|
||||
if ([".png", ".jpeg", ".jpg", ".webp"].find((e) => ural.endsWith(e))) {
|
||||
content = (
|
||||
<div className="rounded-lg flex-grow overflow-hidden">
|
||||
<img
|
||||
onClick={() => {
|
||||
let _url = siteurl(ural || "");
|
||||
window.open(_url, "_blank");
|
||||
}}
|
||||
className={cx(
|
||||
"rounded-md h-full object-cover",
|
||||
css`
|
||||
&:hover {
|
||||
outline: 2px solid #1c4ed8;
|
||||
}
|
||||
`,
|
||||
css`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
#ccc 25%,
|
||||
transparent 25%
|
||||
),
|
||||
linear-gradient(135deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(135deg, transparent 75%, #ccc 75%);
|
||||
background-size: 25px 25px; /* Must be a square */
|
||||
background-position: 0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
|
||||
`
|
||||
)}
|
||||
src={ural && ural.startsWith("blob:") ? ura : ural}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.extension && (
|
||||
<div
|
||||
className={cx(
|
||||
"flex rounded items-center px-1 cursor-pointer flex-grow hover:bg-gray-100 gap-x-1 justify-between",
|
||||
"pr-2",
|
||||
css`
|
||||
&:hover {
|
||||
// border: 1px solid #1c4ed8;
|
||||
// outline: 1px solid #1c4ed8;
|
||||
}
|
||||
&:hover {
|
||||
// border-bottom: 1px solid #1c4ed8;
|
||||
// outline: 1px solid #1c4ed8;
|
||||
}
|
||||
`,
|
||||
disabled ? "bg-transparent" : "bg-white"
|
||||
)}
|
||||
onClick={() => {
|
||||
let _url: any =
|
||||
url && url.startsWith("blob:") ? ura : siteurl(ura || "");
|
||||
window.open(_url, "_blank");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row gap-x-1 items-center">
|
||||
<div className="h-[30px] flex flex-row items-center">{content}</div>
|
||||
<div className="text-xs filename">
|
||||
{limit_name && file?.name
|
||||
? file?.name.substring(0, limit_name)
|
||||
: file?.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2">
|
||||
<ExternalLink size="12px" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const FilePreviewBetter = ({
|
||||
url,
|
||||
disabled,
|
||||
filename,
|
||||
}: {
|
||||
url: any;
|
||||
disabled?: boolean;
|
||||
filename?: string;
|
||||
}) => {
|
||||
const file: any = extractFileInfo(filename || url);
|
||||
const color = colorOfExtension(file.extension);
|
||||
let content = (
|
||||
<div
|
||||
className={cx(
|
||||
"flex items-center justify-center w-8 h-8 rounded-lg ",
|
||||
css`
|
||||
background: ${color?.background};
|
||||
border: 1px solid ${color?.color};
|
||||
color: ${color?.color};
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
padding: 0px 5px;
|
||||
font-size: 9px;
|
||||
margin-right: 5px;
|
||||
`,
|
||||
"flex items-center"
|
||||
)}
|
||||
>
|
||||
{file.extension}
|
||||
</div>
|
||||
);
|
||||
if (
|
||||
file?.fullname &&
|
||||
[".png", ".jpeg", ".jpg", ".webp"].find((e) => file?.fullname.endsWith(e))
|
||||
) {
|
||||
content = (
|
||||
<div className="rounded-lg flex-grow overflow-hidden">
|
||||
<img
|
||||
onClick={() => {
|
||||
let _url = siteurl(url || "");
|
||||
window.open(_url, "_blank");
|
||||
}}
|
||||
className={cx(
|
||||
"rounded-md w-8 h-8 object-cover",
|
||||
css`
|
||||
&:hover {
|
||||
outline: 2px solid #1c4ed8;
|
||||
}
|
||||
`,
|
||||
css`
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
#ccc 25%,
|
||||
transparent 25%
|
||||
),
|
||||
linear-gradient(135deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(135deg, transparent 75%, #ccc 75%);
|
||||
background-size: 25px 25px; /* Must be a square */
|
||||
background-position: 0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
|
||||
`
|
||||
)}
|
||||
src={url}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.extension && (
|
||||
<div
|
||||
className={cx(
|
||||
"flex max-w-full rounded items-center px-1 cursor-pointer flex-grow hover:bg-gray-100 gap-x-1 justify-between",
|
||||
"pr-2",
|
||||
css`
|
||||
&:hover {
|
||||
// border: 1px solid #1c4ed8;
|
||||
// outline: 1px solid #1c4ed8;
|
||||
}
|
||||
&:hover {
|
||||
// border-bottom: 1px solid #1c4ed8;
|
||||
// outline: 1px solid #1c4ed8;
|
||||
}
|
||||
`,
|
||||
disabled ? "bg-transparent" : "bg-white"
|
||||
)}
|
||||
onClick={() => {
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row gap-x-1 items-center">
|
||||
<div className=" flex flex-row items-center">{content}</div>
|
||||
<div className="text-xs filename line-clamp-1 break-all">
|
||||
{file?.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2">
|
||||
<ExternalLink size="12px" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
function darkenColor(color: string, factor: number = 0.5): string {
|
||||
const rgb = hexToRgb(color);
|
||||
const r = Math.floor(rgb.r * factor);
|
||||
const g = Math.floor(rgb.g * factor);
|
||||
const b = Math.floor(rgb.b * factor);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${r.toString(16).padStart(2, "0")}${g
|
||||
.toString(16)
|
||||
.padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
function generateRandomColor(str: string): string {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash.toString(); // Return a string representation of the hash
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash = hash & hash;
|
||||
}
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 255;
|
||||
color += ("00" + value.toString(16)).substr(-2);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
const getFileName = (url: string) => {
|
||||
if (url && typeof url === "string" && url.startsWith("[")) {
|
||||
try {
|
||||
const list = JSON.parse(url);
|
||||
if (list.length === 0) return "Empty";
|
||||
return `${list.length} File${list.length > 1 ? "s" : ""}`;
|
||||
} catch (e) {
|
||||
console.error(`Error parsing multi-file: ${url}`);
|
||||
}
|
||||
return "Unknown File";
|
||||
}
|
||||
|
||||
const fileName = url.substring(url.lastIndexOf("/") + 1);
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
const fullname = fileName;
|
||||
if (dotIndex === -1) {
|
||||
return { name: fileName, extension: "", fullname };
|
||||
}
|
||||
const name = fileName.substring(0, dotIndex);
|
||||
const extension = fileName.substring(dotIndex + 1);
|
||||
return { name, extension, fullname };
|
||||
};
|
||||
|
||||
const extractFileInfo = (url: string) => {
|
||||
let fileName = url.split("/").pop();
|
||||
if (fileName) {
|
||||
let parts = fileName.split(".");
|
||||
let extension = parts.length > 1 ? parts.pop() : "";
|
||||
let name = parts.join(".");
|
||||
|
||||
return {
|
||||
name: name,
|
||||
fullname: fileName,
|
||||
extension: extension,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
name: null,
|
||||
fullname: null,
|
||||
extension: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
export const ImgThumb = ({
|
||||
className,
|
||||
url,
|
||||
w,
|
||||
h,
|
||||
fit,
|
||||
}: {
|
||||
className?: string;
|
||||
url: string;
|
||||
w: number;
|
||||
h: number;
|
||||
fit?: "cover" | "contain" | "inside" | "fill" | "outside";
|
||||
}) => {
|
||||
const local = useLocal({ error: false });
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"img-thumb",
|
||||
className,
|
||||
css`
|
||||
width: ${w}px;
|
||||
height: ${h}px;
|
||||
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(135deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(135deg, transparent 75%, #ccc 75%);
|
||||
background-size: 25px 25px; /* Must be a square */
|
||||
background-position: 0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
|
||||
`
|
||||
)}
|
||||
>
|
||||
{!local.error && url && (
|
||||
<img
|
||||
onError={() => {
|
||||
local.error = true;
|
||||
local.render();
|
||||
}}
|
||||
src={siteurl(
|
||||
`/_img/${url.substring("_file/".length)}?w=${w}&h=${h}&fit=${
|
||||
fit || "cover"
|
||||
}`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getRandomColorPair = () => {
|
||||
const colors = [
|
||||
{ color: "#dc2626", background: "#fbd5d5" },
|
||||
{ color: "#2563eb", background: "#dbeafe" },
|
||||
{ color: "#16a34a", background: "#dcfce7" },
|
||||
{ color: "#6b7280", background: "#f3f4f6" },
|
||||
{ color: "#7c3aed", background: "#ede9fe" },
|
||||
{ color: "#f97316", background: "#ffedd5" },
|
||||
{ color: "#0d9488", background: "#ccfbf1" },
|
||||
{ color: "#9333ea", background: "#e9d5ff" },
|
||||
{ color: "#eab308", background: "#fef9c3" },
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
const colorOfExtension = (extension: string) => {
|
||||
const colorMap: any = {
|
||||
pdf: { color: "#dc2626", background: "#fbd5d5" },
|
||||
doc: { color: "#2563eb", background: "#dbeafe" },
|
||||
docx: { color: "#2563eb", background: "#dbeafe" },
|
||||
xls: { color: "#16a34a", background: "#dcfce7" },
|
||||
xlsx: { color: "#16a34a", background: "#dcfce7" },
|
||||
txt: { color: "#6b7280", background: "#f3f4f6" },
|
||||
zip: { color: "#7c3aed", background: "#ede9fe" },
|
||||
rar: { color: "#7c3aed", background: "#ede9fe" },
|
||||
mp4: { color: "#f97316", background: "#ffedd5" },
|
||||
mp3: { color: "#0d9488", background: "#ccfbf1" },
|
||||
};
|
||||
|
||||
return colorMap[extension] || getRandomColorPair();
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export const TypeInput: React.FC<any> = () => {
|
||||
return (
|
||||
<>
|
||||
<Input id="name" name="name" placeholder="Type product name" required />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
import { AsyncPaginate } from "react-select-async-paginate";
|
||||
import { components } from "react-select";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { empty } from "@/lib/utils/isStringEmpty";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import get from "lodash.get";
|
||||
import { Popover } from "../../Popover/Popover";
|
||||
|
||||
export const TypeAsyncDropdown: React.FC<any> = ({
|
||||
name,
|
||||
fm,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
onValue,
|
||||
onLabel,
|
||||
onLoad,
|
||||
fields,
|
||||
target,
|
||||
mode = "dropdown",
|
||||
placeholder,
|
||||
pagination = true,
|
||||
search = "api",
|
||||
required = false,
|
||||
autoRefresh = false,
|
||||
}) => {
|
||||
const [cacheUniq, setCacheUniq] = useState(0);
|
||||
const [open, setOpen] = useState(false as boolean);
|
||||
const [refreshKey, setRefreshKey] = useState(Date.now());
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState<number>(0);
|
||||
const getValue =
|
||||
typeof onValue === "string"
|
||||
? (e: any) => {
|
||||
if (typeof e !== "object" && !Array.isArray(e)) {
|
||||
return e;
|
||||
}
|
||||
return get(e, onValue);
|
||||
}
|
||||
: onValue;
|
||||
const getLabel =
|
||||
typeof onLabel === "string"
|
||||
? (e: any) => {
|
||||
if (typeof e !== "object" && !Array.isArray(e)) {
|
||||
return e;
|
||||
}
|
||||
return get(e, onLabel);
|
||||
}
|
||||
: onLabel;
|
||||
let placeholderField =
|
||||
mode === "multi"
|
||||
? placeholder || `Add ${label}`
|
||||
: placeholder || `Select ${label}`;
|
||||
const field = useLocal({
|
||||
data: [] as any[],
|
||||
reload: async () => {
|
||||
setRefreshKey(Date.now());
|
||||
},
|
||||
});
|
||||
const debouncedLoadOptions = useMemo(
|
||||
() =>
|
||||
debounce(async (searchQuery: string, page: number, resolve: any) => {
|
||||
let paging = page;
|
||||
if (pagination === false && paging > 1) {
|
||||
resolve({
|
||||
options: [],
|
||||
hasMore: false,
|
||||
additional: {
|
||||
page: searchQuery ? 2 : page + 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
let result: any = await onLoad(
|
||||
search === "local"
|
||||
? {
|
||||
skip: page > 1 ? (page - 1) * 10 : 0,
|
||||
take: 10,
|
||||
}
|
||||
: {
|
||||
skip: page > 1 ? (page - 1) * 10 : 0,
|
||||
take: 10,
|
||||
search: searchQuery,
|
||||
}
|
||||
);
|
||||
if (Array.isArray(result) && result.length) {
|
||||
result = result.map((e) => {
|
||||
return {
|
||||
...e,
|
||||
label: getLabel(e),
|
||||
value: getValue(e),
|
||||
};
|
||||
});
|
||||
}
|
||||
const respon = result;
|
||||
if (
|
||||
pagination === false &&
|
||||
Array.isArray(respon) &&
|
||||
!empty(searchQuery) &&
|
||||
search === "local"
|
||||
) {
|
||||
let filter = respon?.length
|
||||
? respon.filter((e: any) => {
|
||||
const label = getLabel(e) || "";
|
||||
return label
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
})
|
||||
: [];
|
||||
resolve({
|
||||
options: filter,
|
||||
hasMore: filter.length >= 1,
|
||||
additional: {
|
||||
page: searchQuery ? 2 : page + 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
options: respon,
|
||||
hasMore: respon.length >= 1,
|
||||
additional: {
|
||||
page: searchQuery ? 2 : page + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 200),
|
||||
[]
|
||||
);
|
||||
const loadOptions: any = async (
|
||||
searchQuery: any,
|
||||
loadedOptions: any,
|
||||
{ page }: any
|
||||
) => {
|
||||
return new Promise((resolve) => {
|
||||
debouncedLoadOptions(searchQuery, page, resolve);
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!fm?.fields?.[name]) {
|
||||
fm.fields[name] = { ...fields, ...field };
|
||||
fm.render();
|
||||
}
|
||||
}, []);
|
||||
const MultiValue = (props: any) => {
|
||||
return (
|
||||
<components.MultiValue
|
||||
{...props}
|
||||
className={cx(
|
||||
"selected-multi-value rounded-lg bg-gray-200 ",
|
||||
css`
|
||||
border-radius: 6px !important;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</components.MultiValue>
|
||||
);
|
||||
};
|
||||
const Option = (props: any) => {
|
||||
const { data, isSelected, isFocused } = props;
|
||||
return (
|
||||
<components.Option
|
||||
{...props}
|
||||
className={cx(
|
||||
css`
|
||||
cursor: pointer !important;
|
||||
padding: 0px !important;
|
||||
background: transparent !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !imporatnt;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
`pointer-events-auto opt-item px-3 py-1 cursor-pointer option-item text-sm text-black ${
|
||||
isSelected ? "selected" : " hover:bg-blue-50"
|
||||
} ${isFocused ? "focused" : ""}`,
|
||||
"border-t"
|
||||
)}
|
||||
>
|
||||
{getLabel(data)}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
const clearable =
|
||||
mode === "dropdown" && required ? false : mode === "multi" ? true : true;
|
||||
let value = fm.data[name];
|
||||
if (value) {
|
||||
if (mode === "multi") {
|
||||
if (Array.isArray(value) && value?.length) {
|
||||
value = value.map((e) => {
|
||||
return {
|
||||
...e,
|
||||
value: getValue(e),
|
||||
label: getLabel(e),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
value = [];
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
value = value
|
||||
? {
|
||||
value: getValue(value),
|
||||
label: getLabel(value),
|
||||
}
|
||||
: null;
|
||||
} else if (
|
||||
!target &&
|
||||
typeof value !== "object" &&
|
||||
typeof value === "string" &&
|
||||
value
|
||||
) {
|
||||
value = value
|
||||
? onValue === onLabel
|
||||
? {
|
||||
value: value,
|
||||
label: value,
|
||||
}
|
||||
: {
|
||||
value: value,
|
||||
label: getLabel(value),
|
||||
}
|
||||
: null;
|
||||
} else if (typeof value === "string") {
|
||||
value =
|
||||
onValue === onLabel
|
||||
? {
|
||||
value: value,
|
||||
label: value,
|
||||
}
|
||||
: {
|
||||
value: value,
|
||||
label: typeof onLabel === "string" ? value : getLabel(value),
|
||||
};
|
||||
} else if (Array.isArray(value) && value?.length) {
|
||||
value = value.map((e) => {
|
||||
return {
|
||||
...e,
|
||||
value: getValue(e),
|
||||
label: getLabel(e),
|
||||
};
|
||||
});
|
||||
} else if (typeof value === "object" && value) {
|
||||
value = value
|
||||
? {
|
||||
...value,
|
||||
value: getValue(value),
|
||||
label: getLabel(value),
|
||||
}
|
||||
: value;
|
||||
}
|
||||
}
|
||||
const CustomMenu = (props: any) => {
|
||||
return (
|
||||
<Popover
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
classNameTrigger={""}
|
||||
arrow={false}
|
||||
className="rounded-md"
|
||||
onOpenChange={(open: any) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
open={true}
|
||||
content={
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col flex-grow",
|
||||
css`
|
||||
width: ${width}px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (selectRef.current) {
|
||||
setWidth(selectRef.current.offsetWidth);
|
||||
}
|
||||
}, [selectRef]);
|
||||
const customFilterOption = (option: any, rawInput: any) => {
|
||||
// option.data berisi data opsi, misalnya label dan value
|
||||
// rawInput adalah string yang diketik pengguna
|
||||
console.log(option, rawInput);
|
||||
return true;
|
||||
};
|
||||
const increaseUniq = (uniq: number) => uniq + 1;
|
||||
return (
|
||||
<div ref={selectRef} className="w-full">
|
||||
<AsyncPaginate
|
||||
menuIsOpen={open}
|
||||
key={refreshKey}
|
||||
placeholder={disabled ? "" : placeholderField}
|
||||
isDisabled={disabled}
|
||||
className={cx(
|
||||
"rounded-md border-none text-sm w-full",
|
||||
css`
|
||||
[role="listbox"] {
|
||||
padding: 0px !important;
|
||||
z-index: 5;
|
||||
}
|
||||
input:focus {
|
||||
outline: 0px !important;
|
||||
border: 0px !important;
|
||||
outline-offset: 0px !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
}
|
||||
.css-13cymwt-control {
|
||||
border-color: transparent;
|
||||
border-width: 0px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.css-t3ipsp-control {
|
||||
border-color: transparent;
|
||||
border-width: 0px;
|
||||
box-shadow: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
> :nth-child(4) {
|
||||
z-index: 4 !important;
|
||||
}
|
||||
`,
|
||||
disabled
|
||||
? css`
|
||||
> div {
|
||||
border-width: 0px !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
> div > div:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
> div > div:first-child > div {
|
||||
color: black !important;
|
||||
}
|
||||
`
|
||||
: ``
|
||||
)}
|
||||
isClearable={clearable}
|
||||
onMenuOpen={() => {
|
||||
if (autoRefresh) setCacheUniq(increaseUniq);
|
||||
setOpen(true);
|
||||
}}
|
||||
onMenuClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
closeMenuOnSelect={mode === "dropdown" ? true : false}
|
||||
// closeMenuOnSelect={false}
|
||||
cacheUniqs={[cacheUniq]}
|
||||
getOptionValue={(item) => item.value}
|
||||
getOptionLabel={(item) => item.label}
|
||||
value={value}
|
||||
components={{ MultiValue, Option, Menu: CustomMenu }}
|
||||
loadOptions={loadOptions}
|
||||
isSearchable={true}
|
||||
// filterOption={customFilterOption}
|
||||
isMulti={mode === "multi"}
|
||||
onChange={(e) => {
|
||||
setOpen(mode === "dropdown" ? false : true);
|
||||
if (target) {
|
||||
fm.data[target] = getValue(e);
|
||||
}
|
||||
if (mode === "dropdown" && !target) {
|
||||
fm.data[name] = getValue(e);
|
||||
} else {
|
||||
fm.data[name] = e;
|
||||
}
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange({ ...e, data: e });
|
||||
}
|
||||
}}
|
||||
additional={{
|
||||
page: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { FC, useEffect } from "react";
|
||||
|
||||
export const FieldCheckbox: FC<any> = ({
|
||||
fm,
|
||||
name,
|
||||
onLoad,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
mode,
|
||||
fields,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
list: [] as any[],
|
||||
reload: async () => {
|
||||
fm.fields[name] = { ...fields, ...local };
|
||||
fm.render();
|
||||
const callback = (res: any[]) => {
|
||||
if (Array.isArray(res)) {
|
||||
local.list = res;
|
||||
} else {
|
||||
local.list = [];
|
||||
}
|
||||
|
||||
local.render();
|
||||
};
|
||||
const res = onLoad();
|
||||
if (res instanceof Promise) res.then(callback);
|
||||
else callback(res);
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
fm.fields[name] = { ...fields, ...local };
|
||||
const callback = (res: any[]) => {
|
||||
if (Array.isArray(res)) {
|
||||
local.list = res;
|
||||
} else {
|
||||
local.list = [];
|
||||
}
|
||||
local.render();
|
||||
};
|
||||
const res = onLoad();
|
||||
if (res instanceof Promise) res.then(callback);
|
||||
else callback(res);
|
||||
}, []);
|
||||
|
||||
let value =
|
||||
mode === "single" && typeof fm.data?.[name] === "string"
|
||||
? [fm.data?.[name]]
|
||||
: fm.data?.[name];
|
||||
|
||||
let is_tree = false;
|
||||
const applyChanges = (selected: any[]) => {
|
||||
selected = selected.filter((e) => e);
|
||||
const val = selected.map((e) => e.value);
|
||||
if (mode === "single") {
|
||||
selected = val?.[0];
|
||||
|
||||
fm.data[name] = selected;
|
||||
} else {
|
||||
fm.data[name] = val;
|
||||
}
|
||||
fm.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(fm.data[name]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className={cx("flex items-center w-full flex-row")}>
|
||||
<div
|
||||
className={cx(
|
||||
`flex flex-col p-0.5 flex-1`,
|
||||
!is_tree && "space-y-1 ",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{local.list.map((item, idx) => {
|
||||
let isChecked = false;
|
||||
try {
|
||||
isChecked = value.some((e: any) => e === item.value);
|
||||
} catch (ex) {}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx + "_checkbox"}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
let selected = Array.isArray(value)
|
||||
? value.map((row) => {
|
||||
return local.list.find((e) => e.value === row);
|
||||
})
|
||||
: [];
|
||||
|
||||
if (isChecked) {
|
||||
selected = selected.filter(
|
||||
(e: any) => e.value !== item.value
|
||||
);
|
||||
} else {
|
||||
if (mode === "single") {
|
||||
selected = [item];
|
||||
} else {
|
||||
selected.push(item);
|
||||
}
|
||||
}
|
||||
applyChanges(selected);
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"text-sm opt-item flex flex-row space-x-1 cursor-pointer items-center rounded-full p-0.5",
|
||||
isChecked && "active"
|
||||
)}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{isChecked ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="fill-sky-500"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m10.6 14.092l-2.496-2.496q-.14-.14-.344-.15q-.204-.01-.364.15t-.16.354q0 .194.16.354l2.639 2.638q.242.243.565.243q.323 0 .565-.243l5.477-5.477q.14-.14.15-.344q.01-.204-.15-.363q-.16-.16-.354-.16q-.194 0-.353.16L10.6 14.092ZM5.615 20q-.69 0-1.152-.462Q4 19.075 4 18.385V5.615q0-.69.463-1.152Q4.925 4 5.615 4h12.77q.69 0 1.152.463q.463.462.463 1.152v12.77q0 .69-.462 1.152q-.463.463-1.153.463H5.615Z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.615 20q-.69 0-1.152-.462Q4 19.075 4 18.385V5.615q0-.69.463-1.152Q4.925 4 5.615 4h12.77q.69 0 1.152.463q.463.462.463 1.152v12.77q0 .69-.462 1.152q-.463.463-1.153.463H5.615Zm0-1h12.77q.23 0 .423-.192q.192-.193.192-.423V5.615q0-.23-.192-.423Q18.615 5 18.385 5H5.615q-.23 0-.423.192Q5 5.385 5 5.615v12.77q0 .23.192.423q.193.192.423.192Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="">{item.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import tinycolor from "tinycolor2";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
|
||||
export const TypeColor: React.FC<any> = ({
|
||||
value,
|
||||
onChangePicker,
|
||||
onClose,
|
||||
onChange,
|
||||
}) => {
|
||||
const meta = useLocal({
|
||||
originalValue: "",
|
||||
inputValue: value,
|
||||
rgbValue: "",
|
||||
selectedEd: "" as string,
|
||||
});
|
||||
useEffect(() => {
|
||||
meta.inputValue = value || "";
|
||||
const convertColor = tinycolor(meta.inputValue);
|
||||
meta.rgbValue = convertColor.toRgbString();
|
||||
meta.render();
|
||||
}, [value]);
|
||||
const tin = tinycolor(meta.inputValue);
|
||||
return (
|
||||
<div className="flex p-3 space-x-4 items-start">
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col items-center",
|
||||
css`
|
||||
.react-colorful__pointer {
|
||||
border-radius: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Suspense>
|
||||
<HexColorPicker
|
||||
color={meta.inputValue}
|
||||
onChange={(color) => {
|
||||
if (color) {
|
||||
meta.inputValue = color;
|
||||
onChangePicker(color);
|
||||
const convertColor = tinycolor(meta.inputValue);
|
||||
meta.rgbValue = convertColor.toRgbString();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"grid grid-cols-1 gap-y-0.5",
|
||||
css`
|
||||
width: 78px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="p-[1px] border rounded flex items-center justify-center"
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={meta.inputValue || "#FFFFFFFF"}
|
||||
className={cx(
|
||||
`rounded cursor-text bg-[${meta.inputValue}] min-w-[0px] text-[13px] px-[8px] py-[1px] uppercase`,
|
||||
tin.isValid() &&
|
||||
css`
|
||||
color: ${!tin.isLight() ? "#FFF" : "#000"};
|
||||
background-color: ${meta.inputValue || ""};
|
||||
`
|
||||
)}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
const color = e.currentTarget.value;
|
||||
meta.inputValue = color;
|
||||
onChangePicker(color);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{meta.inputValue !== "" && (
|
||||
<>
|
||||
<div
|
||||
className="cursor-pointer text-center border border-gray-200 rounded hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
meta.inputValue = "";
|
||||
onChangePicker("");
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<div
|
||||
className="cursor-pointer text-center border border-gray-200 rounded hover:bg-gray-100 mt-[4px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { Typeahead } from "./Typeahead";
|
||||
|
||||
export const TypeDropdown: React.FC<any> = ({
|
||||
required,
|
||||
fm,
|
||||
name,
|
||||
onLoad,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
mode,
|
||||
allowNew = false,
|
||||
unique = true,
|
||||
isBetter = false,
|
||||
fields,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Typeahead
|
||||
value={
|
||||
Array.isArray(fm.data?.[name])
|
||||
? fm.data?.[name]
|
||||
: fm.data?.[name]
|
||||
? [fm.data?.[name]]
|
||||
: []
|
||||
}
|
||||
isBetter={isBetter}
|
||||
allowNew={allowNew}
|
||||
unique={mode === "multi" ? (isBetter ? false : true) : false}
|
||||
disabledSearch={false}
|
||||
// popupClassName={}
|
||||
required={required}
|
||||
onSelect={({ search, item }) => {
|
||||
if (item) {
|
||||
if (mode === "multi") {
|
||||
if (!Array.isArray(fm.data[name])) {
|
||||
fm.data[name] = [];
|
||||
fm.render();
|
||||
}
|
||||
fm.data[name].push(item.value);
|
||||
fm.render();
|
||||
} else {
|
||||
fm.data[name] = item.value;
|
||||
fm.render();
|
||||
}
|
||||
}
|
||||
if (typeof onChange === "function" && item) {
|
||||
onChange(item);
|
||||
}
|
||||
return item?.value || search;
|
||||
}}
|
||||
disabled={disabled}
|
||||
// allowNew={false}
|
||||
autoPopupWidth={true}
|
||||
focusOpen={true}
|
||||
mode={mode ? mode : "single"}
|
||||
placeholder={placeholder}
|
||||
options={onLoad}
|
||||
onInit={(e) => {
|
||||
fm.fields[name] = {
|
||||
...fields,
|
||||
...e,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { TypeaheadBetter } from "./TypeaheadBetter";
|
||||
|
||||
export const TypeDropdownBetter: React.FC<any> = ({
|
||||
required,
|
||||
fm,
|
||||
name,
|
||||
onLoad,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
mode,
|
||||
allowNew = false,
|
||||
unique = true,
|
||||
isBetter = false,
|
||||
onCount,
|
||||
onLabel,
|
||||
fields,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<TypeaheadBetter
|
||||
value={
|
||||
Array.isArray(fm.data?.[name])
|
||||
? fm.data?.[name]
|
||||
: fm.data?.[name]
|
||||
? [fm.data?.[name]]
|
||||
: []
|
||||
}
|
||||
onCount={onCount}
|
||||
onLoad={onLoad}
|
||||
onLabel={onLabel}
|
||||
isBetter={isBetter}
|
||||
allowNew={allowNew}
|
||||
unique={mode === "multi" ? (isBetter ? false : true) : false}
|
||||
disabledSearch={false}
|
||||
required={required}
|
||||
onSelect={({ search, item }) => {
|
||||
if (item) {
|
||||
if (mode === "multi") {
|
||||
if (!Array.isArray(fm.data[name])) {
|
||||
fm.data[name] = [];
|
||||
fm.render();
|
||||
}
|
||||
fm.data[name].push(item.value);
|
||||
fm.render();
|
||||
} else {
|
||||
fm.data[name] = item.value;
|
||||
fm.render();
|
||||
}
|
||||
}
|
||||
if (typeof onChange === "function" && item) {
|
||||
onChange(item);
|
||||
}
|
||||
return item?.value || search;
|
||||
}}
|
||||
disabled={disabled}
|
||||
// allowNew={false}
|
||||
autoPopupWidth={true}
|
||||
focusOpen={true}
|
||||
mode={mode ? mode : "single"}
|
||||
placeholder={placeholder}
|
||||
options={onLoad}
|
||||
onInit={(e) => {
|
||||
fm.fields[name] = { ...fields, ...e };
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,552 @@
|
|||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import Datepicker from "../../ui/Datepicker";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Textarea } from "../../ui/text-area";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import tinycolor from "tinycolor2";
|
||||
import { FieldColorPicker } from "../../ui/FieldColorPopover";
|
||||
import { Rating } from "../../ui/ratings";
|
||||
import { getNumber } from "@/lib/utils/getNumber";
|
||||
import MaskedInput from "../../ui/MaskedInput";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { time } from "@/lib/utils/date";
|
||||
import React from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import { IconEye, IconEyeClosed } from "@tabler/icons-react";
|
||||
|
||||
export const TypeInput: React.FC<any> = ({
|
||||
name,
|
||||
fm,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
required,
|
||||
type,
|
||||
field,
|
||||
onChange,
|
||||
className,
|
||||
placeholderDate,
|
||||
isDebounce = false,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(null as any);
|
||||
|
||||
const [hover, setHover] = useState(0); // State untuk menyimpan nilai hover
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
let value: any = fm.data?.[name] || "";
|
||||
|
||||
const [rating, setRating] = useState(value); // State untuk menyimpan nilai rating
|
||||
const handleClick = (index: number) => {
|
||||
setRating(index); // Update nilai rating
|
||||
fm.data[name] = rating + 1;
|
||||
fm.render();
|
||||
};
|
||||
const input = useLocal({
|
||||
value: 0 as any,
|
||||
ref: null as any,
|
||||
show_pass: false as boolean,
|
||||
open: false,
|
||||
});
|
||||
const meta = useLocal({
|
||||
originalValue: "",
|
||||
inputValue: value,
|
||||
rgbValue: "",
|
||||
selectedEd: "" as string,
|
||||
});
|
||||
const handleInput = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto"; // Reset height to calculate new height
|
||||
textarea.style.height = `${textarea.scrollHeight}px`; // Adjust height based on content
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (type === "color") {
|
||||
meta.inputValue = value || "";
|
||||
const convertColor = tinycolor(meta.inputValue);
|
||||
meta.rgbValue = convertColor.toRgbString();
|
||||
meta.render();
|
||||
} else {
|
||||
setRating(value ? value - 1 : value);
|
||||
}
|
||||
}, [value]);
|
||||
useEffect(() => {
|
||||
if (type === "money") {
|
||||
input.value =
|
||||
typeof fm.data?.[name] === "number" && fm.data?.[name] === 0
|
||||
? "0"
|
||||
: formatCurrency(value);
|
||||
input.render();
|
||||
}
|
||||
}, [fm.data?.[name]]);
|
||||
const error = fm.error?.[name];
|
||||
switch (type) {
|
||||
case "textarea":
|
||||
return (
|
||||
<>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id={name}
|
||||
onInput={handleInput}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={cx(
|
||||
"text-sm border-none",
|
||||
css`
|
||||
background-color: ${disabled
|
||||
? "rgb(243 244 246)"
|
||||
: "transparant"}
|
||||
? "";
|
||||
`
|
||||
)}
|
||||
placeholder={placeholder || ""}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
fm.data[name] = ev.currentTarget.value;
|
||||
fm.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(ev.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case "time":
|
||||
return (
|
||||
<>
|
||||
<MaskedInput
|
||||
value={time(value) || ""}
|
||||
disabled={disabled}
|
||||
className={cx(
|
||||
css`
|
||||
background-color: ${disabled
|
||||
? "rgb(243 244 246)"
|
||||
: "transparant"};
|
||||
`,
|
||||
className
|
||||
)}
|
||||
onChange={(value) => {
|
||||
if (disabled) return;
|
||||
fm.data[name] = value;
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case "rating":
|
||||
return (
|
||||
<div className="flex">
|
||||
<Rating
|
||||
rating={getNumber(fm.data?.[name])}
|
||||
totalStars={5}
|
||||
size={24}
|
||||
variant="yellow"
|
||||
disabled={disabled}
|
||||
className="h-1"
|
||||
showText={false}
|
||||
onRatingChange={(e) => {
|
||||
fm.data[name] = getNumber(e);
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(getNumber(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
case "color":
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="border border-gray-300 p-0.5 rounded-sm">
|
||||
<FieldColorPicker
|
||||
value={fm.data?.[name]}
|
||||
update={(val) => {
|
||||
fm.data[name] = val;
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(val);
|
||||
}
|
||||
}}
|
||||
onOpen={() => {
|
||||
input.open = true;
|
||||
input.render();
|
||||
}}
|
||||
onClose={() => {
|
||||
input.open = false;
|
||||
input.render();
|
||||
}}
|
||||
open={input.open}
|
||||
showHistory={false}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
css`
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill-opacity=".05"><path d="M8 0h8v8H8zM0 8h8v8H0z"/></svg>');
|
||||
`,
|
||||
"cursor-pointer rounded-md"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-sm h-8 w-8",
|
||||
css`
|
||||
background: ${fm?.data?.[name]};
|
||||
`,
|
||||
"color-box"
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
</FieldColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<>
|
||||
<Datepicker
|
||||
value={{ startDate: value, endDate: value }}
|
||||
disabled={disabled}
|
||||
displayFormat="DD MMM YYYY"
|
||||
placeholder={placeholder || "DD MMM YYYY"}
|
||||
mode={"daily"}
|
||||
maxDate={field?.max_date instanceof Date ? field.max_date : null}
|
||||
minDate={field?.min_date instanceof Date ? field.min_date : null}
|
||||
asSingle={true}
|
||||
useRange={false}
|
||||
onChange={(value) => {
|
||||
fm.data[name] = value?.startDate
|
||||
? new Date(value?.startDate)
|
||||
: null;
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(value?.startDate ? new Date(value?.startDate) : null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case "money":
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"text-sm text-right ",
|
||||
error
|
||||
? css`
|
||||
border-color: red !important;
|
||||
`
|
||||
: ``,
|
||||
css`
|
||||
background-color: ${disabled
|
||||
? "rgb(243 244 246)"
|
||||
: "transparant"}
|
||||
? "";
|
||||
`,
|
||||
className
|
||||
)}
|
||||
required={required}
|
||||
placeholder={placeholder || ""}
|
||||
value={formatCurrency(input.value)}
|
||||
type={"text"}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={(ev) => {
|
||||
const rawValue = ev.currentTarget.value
|
||||
.replace(/[^0-9,-]/g, "")
|
||||
.toString();
|
||||
if (rawValue === "0") {
|
||||
input.value = "0";
|
||||
input.render();
|
||||
}
|
||||
if (
|
||||
(!rawValue.startsWith(",") || !rawValue.endsWith(",")) &&
|
||||
!rawValue.endsWith("-") &&
|
||||
convertionCurrencyNumber(rawValue) !==
|
||||
convertionCurrencyNumber(input.value)
|
||||
) {
|
||||
fm.data[name] = convertionCurrencyNumber(
|
||||
formatCurrency(rawValue)
|
||||
);
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(convertionCurrencyNumber(formatCurrency(rawValue)));
|
||||
}
|
||||
input.value = formatCurrency(fm.data[name]);
|
||||
input.render();
|
||||
} else {
|
||||
input.value = rawValue;
|
||||
input.render();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
}
|
||||
let type_field = type;
|
||||
if (input.show_pass) {
|
||||
type_field = "text";
|
||||
}
|
||||
if (type === "status") {
|
||||
if (disabled) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
id={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
name={name}
|
||||
className={cx(
|
||||
"text-sm",
|
||||
error
|
||||
? css`
|
||||
border-color: red !important;
|
||||
`
|
||||
: ``,
|
||||
css`
|
||||
background-color: ${disabled
|
||||
? "rgb(243 244 246)"
|
||||
: "transparant"}
|
||||
? "";
|
||||
`,
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
placeholder={placeholder || ""}
|
||||
value={value}
|
||||
type={!type ? "text" : type_field}
|
||||
onChange={(ev) => {
|
||||
fm.data[name] = ev.currentTarget.value;
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(ev.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{type === "password" && (
|
||||
<div
|
||||
className=" right-0 h-full flex items-center cursor-pointer px-2"
|
||||
onClick={() => {
|
||||
input.show_pass = !input.show_pass;
|
||||
input.render();
|
||||
}}
|
||||
>
|
||||
<div className="">
|
||||
{input.show_pass ? (
|
||||
<IconEye className="h-4" />
|
||||
) : (
|
||||
<IconEyeClosed className="h-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
type = "text";
|
||||
}
|
||||
}
|
||||
|
||||
const latestValueRef = React.useRef<string | null>(null);
|
||||
|
||||
const debouncedOnChange = React.useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (typeof onChange === "function" && latestValueRef.current !== null) {
|
||||
onChange(latestValueRef.current);
|
||||
}
|
||||
}, 1000),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
latestValueRef.current = event.currentTarget.value; // Simpan nilai terbaru
|
||||
debouncedOnChange(); // Panggil fungsi debounce
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel(); // Bersihkan debounce saat komponen unmount
|
||||
};
|
||||
}, [debouncedOnChange]);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
id={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
name={name}
|
||||
className={cx(
|
||||
"text-sm",
|
||||
error
|
||||
? css`
|
||||
border-color: red !important;
|
||||
`
|
||||
: ``,
|
||||
css`
|
||||
background-color: ${disabled ? "rgb(243 244 246)" : "transparant"} ?
|
||||
"";
|
||||
`,
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
placeholder={placeholder || ""}
|
||||
value={value}
|
||||
type={!type ? "text" : type_field}
|
||||
onChange={(ev) => {
|
||||
setInputValue(ev.currentTarget.value);
|
||||
fm.data[name] = ev.currentTarget.value;
|
||||
fm.render();
|
||||
if (!isDebounce) {
|
||||
if (typeof onChange === "function") {
|
||||
onChange(ev.currentTarget.value);
|
||||
}
|
||||
} else {
|
||||
handleInputChange(ev);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{type === "password" && (
|
||||
<div
|
||||
className=" right-0 h-full flex items-center cursor-pointer px-2"
|
||||
onClick={() => {
|
||||
input.show_pass = !input.show_pass;
|
||||
input.render();
|
||||
}}
|
||||
>
|
||||
<div className="">
|
||||
{input.show_pass ? (
|
||||
<IconEyeClosed className="h-4" />
|
||||
) : (
|
||||
<IconEye className="h-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const convertionCurrencyNumber = (value: string) => {
|
||||
if (!value) return null;
|
||||
let numberString = value.toString().replace(/[^0-9,-]/g, "");
|
||||
if (numberString.endsWith(",")) {
|
||||
return Number(numberString.replace(",", "")) || 0;
|
||||
}
|
||||
if (numberString.endsWith("-")) {
|
||||
return Number(numberString.replace("-", "")) || 0;
|
||||
}
|
||||
const rawValue = numberString.replace(/[^0-9,-]/g, "").replace(",", ".");
|
||||
return parseFloat(rawValue) || 0;
|
||||
};
|
||||
const formatCurrency = (value: any) => {
|
||||
// Menghapus semua karakter kecuali angka, koma, dan tanda minusif (value === null || value === undefined) return '';
|
||||
if (typeof value === "number" && value === 0) return "0";
|
||||
if (typeof value === "string" && value === "0") return "0";
|
||||
if (!value) return "";
|
||||
let numberString = "";
|
||||
if (typeof value === "number") {
|
||||
numberString = formatMoney(value);
|
||||
} else {
|
||||
numberString = value.toString().replace(/[^0-9,-]/g, "");
|
||||
}
|
||||
if (numberString.endsWith("-") && numberString.startsWith("-")) {
|
||||
return "-";
|
||||
} else if (numberString.endsWith(",")) {
|
||||
const isNegative = numberString.startsWith("-");
|
||||
numberString = numberString.replace("-", "");
|
||||
const split = numberString.split(",");
|
||||
if (isNumberOrCurrency(split[0]) === "Number") {
|
||||
split[0] = formatMoney(Number(split[0]));
|
||||
}
|
||||
let rupiah = split[0];
|
||||
rupiah = split[1] !== undefined ? rupiah + "," + split[1] : rupiah;
|
||||
return (isNegative ? "-" : "") + rupiah;
|
||||
} else {
|
||||
const isNegative = numberString.startsWith("-");
|
||||
numberString = numberString.replace("-", "");
|
||||
const split = numberString.split(",");
|
||||
if (isNumberOrCurrency(split[0]) === "Number") {
|
||||
split[0] = formatMoney(Number(split[0]));
|
||||
}
|
||||
let rupiah = split[0];
|
||||
rupiah = split[1] !== undefined ? rupiah + "," + split[1] : rupiah;
|
||||
return (isNegative ? "-" : "") + rupiah;
|
||||
}
|
||||
};
|
||||
export const formatMoney = (res: any) => {
|
||||
if (typeof res === "string" && res.startsWith("BigInt::")) {
|
||||
res = res.substring(`BigInt::`.length);
|
||||
}
|
||||
|
||||
const formattedAmount = new Intl.NumberFormat("id-ID", {
|
||||
minimumFractionDigits: 0,
|
||||
}).format(res);
|
||||
return formattedAmount;
|
||||
};
|
||||
const isNumberOrCurrency = (input: any) => {
|
||||
// Pengecekan apakah input adalah angka biasa
|
||||
|
||||
if (typeof input === "string") {
|
||||
let rs = input;
|
||||
if (input.startsWith("-")) {
|
||||
rs = rs.replace("-", "");
|
||||
}
|
||||
const dots = rs.match(/\./g);
|
||||
if (dots && dots.length > 1) {
|
||||
return "Currency";
|
||||
} else if (dots && dots.length === 1) {
|
||||
if (!hasNonZeroDigitAfterDecimal(rs)) {
|
||||
return "Currency";
|
||||
}
|
||||
return "Currency";
|
||||
}
|
||||
}
|
||||
if (!isNaN(input)) {
|
||||
return "Number";
|
||||
}
|
||||
// Pengecekan apakah input adalah format mata uang dengan pemisah ribuan
|
||||
const currencyRegex = /^-?Rp?\s?\d{1,3}(\.\d{3})*$/;
|
||||
if (currencyRegex.test(input)) {
|
||||
return "Currency";
|
||||
}
|
||||
|
||||
// Jika tidak terdeteksi sebagai angka atau format mata uang, kembalikan null atau sesuai kebutuhan
|
||||
return null;
|
||||
};
|
||||
const hasNonZeroDigitAfterDecimal = (input: string) => {
|
||||
// Ekspresi reguler untuk mencocokkan angka 1-9 setelah koma atau titik
|
||||
const regex = /[.,]\d*[1-9]\d*/;
|
||||
return regex.test(input);
|
||||
};
|
||||
export const convertToTimeOnly = (isoString: any) => {
|
||||
if (!isoString || isoString === "") return null;
|
||||
const isoRegex = /^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}):\d{2}Z$/;
|
||||
|
||||
const match = isoString.match(isoRegex);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return isoString;
|
||||
};
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { FC, useEffect } from "react";
|
||||
import { IconToggleLeft, IconToggleRightFilled } from "@tabler/icons-react";
|
||||
|
||||
export const FieldRadio: FC<any> = ({
|
||||
fm,
|
||||
name,
|
||||
onLoad,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
mode = "single",
|
||||
fields,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
list: [] as any[],
|
||||
reload: async () => {
|
||||
fm.fields[name] = { ...fields, ...local };
|
||||
fm.render();
|
||||
const callback = (res: any[]) => {
|
||||
if (Array.isArray(res)) {
|
||||
local.list = res;
|
||||
} else {
|
||||
local.list = [];
|
||||
}
|
||||
|
||||
local.render();
|
||||
};
|
||||
const res = onLoad();
|
||||
if (res instanceof Promise) res.then(callback);
|
||||
else callback(res);
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
fm.fields[name] = { ...fields, ...local };
|
||||
const callback = (res: any[]) => {
|
||||
if (Array.isArray(res)) {
|
||||
local.list = res;
|
||||
} else {
|
||||
local.list = [];
|
||||
}
|
||||
local.render();
|
||||
};
|
||||
const res = onLoad();
|
||||
if (res instanceof Promise) res.then(callback);
|
||||
else callback(res);
|
||||
}, []);
|
||||
|
||||
let value =
|
||||
mode === "single" && typeof fm.data?.[name] === "string"
|
||||
? [fm.data?.[name]]
|
||||
: fm.data?.[name];
|
||||
|
||||
let is_tree = false;
|
||||
const applyChanges = (selected: any[]) => {
|
||||
selected = selected.filter((e) => e);
|
||||
const val = selected.map((e) => e.value);
|
||||
if (mode === "single") {
|
||||
selected = val?.[0];
|
||||
|
||||
fm.data[name] = selected;
|
||||
} else {
|
||||
fm.data[name] = val;
|
||||
}
|
||||
fm.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(fm.data[name]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className={cx("flex items-center w-full flex-row")}>
|
||||
<div
|
||||
className={cn(
|
||||
`flex flex-col p-0.5 flex-1 text-sm `,
|
||||
!is_tree && "space-y-1 ",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{local.list.map((item, idx) => {
|
||||
let isChecked = false;
|
||||
try {
|
||||
isChecked = value.some((e: any) => e === item.value);
|
||||
} catch (ex) {}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx + "_checkbox"}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
let selected = Array.isArray(value)
|
||||
? value.map((row) => {
|
||||
return local.list.find((e) => e.value === row);
|
||||
})
|
||||
: [];
|
||||
|
||||
if (isChecked) {
|
||||
selected = selected.filter(
|
||||
(e: any) => e.value !== item.value
|
||||
);
|
||||
} else {
|
||||
if (mode === "single") {
|
||||
selected = [item];
|
||||
} else {
|
||||
selected.push(item);
|
||||
}
|
||||
}
|
||||
applyChanges(selected);
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"opt-item flex flex-row gap-x-1 cursor-pointer items-center rounded-full p-0.5 ",
|
||||
isChecked && "active"
|
||||
)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<IconToggleRightFilled className="text-primary" />
|
||||
) : (
|
||||
<IconToggleLeft className="text-primary" />
|
||||
)}
|
||||
<div className="">{item.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { cva } from "class-variance-authority";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Checkbox } from "../../ui/checkbox";
|
||||
import { X } from "lucide-react";
|
||||
import { IconToggleLeft, IconToggleRightFilled } from "@tabler/icons-react";
|
||||
|
||||
export const TypeTag: React.FC<any> = ({
|
||||
name,
|
||||
fm,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
required,
|
||||
type,
|
||||
field,
|
||||
onChange,
|
||||
styleField,
|
||||
mode,
|
||||
valueChecked,
|
||||
}) => {
|
||||
const [tags, setTags] = useState<string[]>(fm.data?.[name] || []);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null); // Index tag yang sedang diedit
|
||||
const [tempValue, setTempValue] = useState<string>(""); // Nilai sementara untuk pengeditan
|
||||
const tagRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const val = fm?.data?.[name];
|
||||
|
||||
useEffect(() => {
|
||||
if (editingIndex !== null && tagRefs.current[editingIndex]) {
|
||||
tagRefs.current[editingIndex]?.focus();
|
||||
}
|
||||
}, [editingIndex]);
|
||||
|
||||
const handleSaveEdit = (index: number) => {
|
||||
if (disabled) return;
|
||||
const updatedTags = [...tags];
|
||||
updatedTags[index] = tempValue.trim(); // Update nilai tag
|
||||
setTags(updatedTags);
|
||||
setEditingIndex(null); // Keluar dari mode edit
|
||||
setTempValue(""); // Reset nilai sementara
|
||||
|
||||
fm.data[name] = updatedTags;
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(tags);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" && inputValue) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTags([...tags, inputValue]);
|
||||
setInputValue("");
|
||||
fm.data[name] = [...tags, inputValue];
|
||||
fm.render();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(tags);
|
||||
}
|
||||
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
|
||||
setTags(tags.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusTag = (index: number) => {
|
||||
if (disabled) return;
|
||||
setEditingIndex(index); // Masuk ke mode edit
|
||||
setTempValue(tags[index]); // Isi nilai sementara dengan nilai tag
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
if (disabled) return;
|
||||
setTags(tags.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const buttonVariants = cva(
|
||||
"flex flex-row items-center rounded-full text-sm p-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-blue-100 text-blue-800 m-1",
|
||||
moe: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const stylingGroup = ["checkbox", "radio", "order"];
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex rounded-md flex-grow ",
|
||||
stylingGroup.includes(styleField)
|
||||
? "flex-wrap flex-col"
|
||||
: "items-center flex-wrap",
|
||||
disabled && !tags?.length ? "h-9" : ""
|
||||
)}
|
||||
>
|
||||
{tags.map((tag, index) => {
|
||||
const checked = valueChecked?.length
|
||||
? isChecked(tag, valueChecked)
|
||||
: false;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cx(
|
||||
buttonVariants({ variant: styleField ? styleField : "default" }),
|
||||
editingIndex === index
|
||||
? styleField
|
||||
? "border-b border-gray-500 rounded-none"
|
||||
: "bg-transparent border border-gray-500 rounded-full text-gray-900"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{styleField === "checkbox" ? (
|
||||
<>
|
||||
<Checkbox
|
||||
className="border border-primary"
|
||||
checked={checked}
|
||||
onClick={(e) => {}}
|
||||
/>{" "}
|
||||
</>
|
||||
) : styleField === "radio" ? (
|
||||
<>{checked ? <IconToggleRightFilled /> : <IconToggleLeft />}</>
|
||||
) : styleField === "order" ? (
|
||||
<>
|
||||
{index + 1}
|
||||
{". "}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{disabled ? (
|
||||
<div className="px-2">{tag}</div>
|
||||
) : (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) tagRefs.current[index] = el;
|
||||
}}
|
||||
className={cx(
|
||||
"px-3 py-1 pr-0 flex-grow focus:shadow-none focus:ring-0 focus:border-none focus:outline-none",
|
||||
editingIndex !== index && "cursor-pointer"
|
||||
)}
|
||||
contentEditable={editingIndex === index}
|
||||
suppressContentEditableWarning
|
||||
onBlur={() => handleSaveEdit(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveEdit(index);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingIndex(null);
|
||||
}
|
||||
}}
|
||||
onClick={() => handleFocusTag(index)}
|
||||
onInput={(e) =>
|
||||
setTempValue((e.target as HTMLDivElement).innerText)
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(index)}
|
||||
className="ml-2 text-red-500 pr-2"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!disabled && (
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e: any) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="rounded-md flex-grow border-none outline-none text-sm focus:shadow-none focus:ring-0 focus:border-none focus:outline-none"
|
||||
placeholder="Add a option..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isChecked = (value: string, valueChecked: string[]) => {
|
||||
const findCheck = valueChecked?.find((item) => item === value);
|
||||
return findCheck ? true : false;
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { FieldUploadMulti } from "./TypeUploadMulti";
|
||||
import { FieldUploadSingle } from "./TypeUploadSingle";
|
||||
|
||||
export const TypeUpload: React.FC<any> = ({
|
||||
name,
|
||||
fm,
|
||||
on_change,
|
||||
mode,
|
||||
type,
|
||||
disabled,
|
||||
valueKey = "url",
|
||||
onDelete,
|
||||
style = "directUpload",
|
||||
urlUpload,
|
||||
}) => {
|
||||
if (type === "multi") {
|
||||
return (
|
||||
<>
|
||||
<FieldUploadMulti
|
||||
field={{
|
||||
name,
|
||||
disabled,
|
||||
}}
|
||||
fm={fm}
|
||||
on_change={on_change}
|
||||
mode={mode}
|
||||
valueKey={valueKey}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FieldUploadSingle
|
||||
field={{
|
||||
name,
|
||||
disabled,
|
||||
}}
|
||||
isDirectUpload={style === "directUpload"}
|
||||
fm={fm}
|
||||
on_change={on_change}
|
||||
mode={mode}
|
||||
urlUpload={urlUpload}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { Upload } from "lucide-react";
|
||||
import { ChangeEvent, FC } from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { FilePreviewBetter } from "./FilePreview";
|
||||
import { IconTrashX } from "@tabler/icons-react";
|
||||
|
||||
export const FieldUploadMulti: FC<{
|
||||
field: any;
|
||||
fm: any;
|
||||
on_change: (e: any) => void | Promise<void>;
|
||||
mode?: "upload";
|
||||
valueKey?: string;
|
||||
onDelete?: (e: any) => any | Promise<any>;
|
||||
}> = ({ field, fm, on_change, mode, valueKey = "url", onDelete }) => {
|
||||
const styling = "mini";
|
||||
const disabled = field?.disabled || false;
|
||||
let value: any = fm.data?.[field.name];
|
||||
// let type_upload =
|
||||
const input = useLocal({
|
||||
value: [] as any[],
|
||||
display: false as any,
|
||||
ref: null as any,
|
||||
drop: false as boolean,
|
||||
uploading: new Set<File>(),
|
||||
fase: value ? "preview" : ("start" as "start" | "upload" | "preview"),
|
||||
style: "inline" as "inline" | "full",
|
||||
});
|
||||
|
||||
const on_upload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
let file = null;
|
||||
try {
|
||||
file = event.target?.files?.[0];
|
||||
} catch (ex) {}
|
||||
|
||||
if (event.target.files) {
|
||||
const list = [] as any[];
|
||||
input.fase = "upload";
|
||||
input.render();
|
||||
const files = event.target.files.length;
|
||||
for (let i = 0; i < event.target.files.length; i++) {
|
||||
const file = event.target?.files?.item(i);
|
||||
if (file) {
|
||||
list.push({
|
||||
name: file.name,
|
||||
data: file,
|
||||
[valueKey]: `${URL.createObjectURL(file)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const existing = fm.data[field.name] || [];
|
||||
fm.data[field.name] = existing.concat(list);
|
||||
fm.render();
|
||||
if (typeof on_change === "function") on_change(fm.data?.[field.name]);
|
||||
input.fase = "start";
|
||||
input.render();
|
||||
}
|
||||
|
||||
if (input.ref) {
|
||||
input.ref.value = null;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex-grow flex-col flex w-full h-full items-stretch relative">
|
||||
{!disabled ? (
|
||||
<>
|
||||
{" "}
|
||||
<div className="flex flex-wrap py-1 pb-2">
|
||||
<div className=" relative flex focus-within:border focus-within:border-primary border border-gray-300 rounded-md ">
|
||||
<div
|
||||
className={cx(
|
||||
"hover:bg-gray-50 text-gray-900 text-md rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 ",
|
||||
css`
|
||||
input[type="file"],
|
||||
input[type="file"]::-webkit-file-upload-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
disabled && "bg-gray-50"
|
||||
)}
|
||||
>
|
||||
{!disabled && (
|
||||
<input
|
||||
ref={(ref) => {
|
||||
if (ref) input.ref = ref;
|
||||
}}
|
||||
type="file"
|
||||
multiple={true}
|
||||
// accept={field.prop.upload?.accept}
|
||||
accept={"file/**"}
|
||||
onChange={on_upload}
|
||||
className={cx(
|
||||
"absolute w-full h-full cursor-pointer top-0 left-0 opacity-0"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (input.ref) {
|
||||
input.ref.click();
|
||||
}
|
||||
}}
|
||||
className="items-center flex text-base px-1 outline-none rounded cursor-pointer flex-row justify-center"
|
||||
>
|
||||
<div className="flex flex-row items-center px-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-row items-center text-sm">
|
||||
Add File
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center px-1.5 text-sm">
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.isArray(value) && value?.length ? (
|
||||
<>
|
||||
{value.map((e: any, idx: number) => {
|
||||
return (
|
||||
<div className="flex flex-col" key={`files-${name}-${idx}`}>
|
||||
<div className="flex flex-row items-center w-64 p-2 border rounded-lg shadow-sm bg-white">
|
||||
<div className="flex flex-grow flex-row items-center">
|
||||
<div className="flex flex-grow">
|
||||
<FilePreviewBetter
|
||||
url={e?.[valueKey]}
|
||||
filename={e?.name}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{!disabled ? (
|
||||
<>
|
||||
<div
|
||||
className="hover:bg-gray-100 p-2 rounded-lg cursor-pointer"
|
||||
onClick={() => {
|
||||
fm.data[field.name] = value.filter(
|
||||
(_, i) => i !== idx
|
||||
);
|
||||
fm.render();
|
||||
if (typeof on_change === "function")
|
||||
on_change(fm.data?.[field.name]);
|
||||
|
||||
if (typeof onDelete === "function") onDelete(e);
|
||||
}}
|
||||
>
|
||||
<IconTrashX className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
import get from "lodash.get";
|
||||
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react";
|
||||
import { ChangeEvent, FC, useEffect } from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { siteurl } from "@/lib/utils/siteurl";
|
||||
import { FilePreview } from "./FilePreview";
|
||||
|
||||
export const FieldUploadSingle: FC<{
|
||||
field: any;
|
||||
fm: any;
|
||||
on_change: (e: any) => void | Promise<void>;
|
||||
mode?: "upload" | "import";
|
||||
isDirectUpload?: boolean;
|
||||
urlUpload?: string;
|
||||
}> = ({ field, fm, on_change, mode, isDirectUpload, urlUpload }) => {
|
||||
const styling = "mini";
|
||||
const disabled = field?.disabled || false;
|
||||
let value: any = fm.data?.[field.name];
|
||||
// let type_upload =
|
||||
const input = useLocal({
|
||||
value: 0 as any,
|
||||
display: false as any,
|
||||
ref: null as any,
|
||||
drop: false as boolean,
|
||||
fase: value ? "preview" : ("start" as "start" | "upload" | "preview"),
|
||||
style: "inline" as "inline" | "full",
|
||||
preview: null as any,
|
||||
isLocal: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (value instanceof File) {
|
||||
input.preview = `${URL.createObjectURL(value)}.${value.name
|
||||
.split(".")
|
||||
.pop()}`;
|
||||
input.isLocal = true;
|
||||
input.render();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const on_upload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
let file: File | null = null;
|
||||
try {
|
||||
file = event.target?.files?.[0] ?? null;
|
||||
} catch (ex) {
|
||||
file = null;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
if (input.ref) input.ref.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct upload to provided urlUpload (or fallback to internal upload endpoint)
|
||||
if (isDirectUpload) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
let url = siteurl("/upload");
|
||||
|
||||
input.fase = "upload";
|
||||
input.render();
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Upload failed");
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
let result: any;
|
||||
if (contentType.includes("application/json")) {
|
||||
result = await response.json();
|
||||
}
|
||||
console.log("Upload result", result);
|
||||
|
||||
// Normalize result into a usable value for fm.data[field.name]
|
||||
if (result && typeof result === "object" && !Array.isArray(result)) {
|
||||
// prefer API shape: { url, filename, size }
|
||||
const urlVal =
|
||||
get(result, "url") ||
|
||||
get(result, "path") ||
|
||||
get(result, "file") ||
|
||||
get(result, "filename");
|
||||
if (urlVal) {
|
||||
fm.data[field.name] = urlVal;
|
||||
input.fase = "preview";
|
||||
input.preview = urlVal;
|
||||
input.isLocal = false;
|
||||
} else {
|
||||
// fallback to storing the whole object and show local preview
|
||||
fm.data[field.name] = result;
|
||||
input.fase = "preview";
|
||||
input.preview = `${URL.createObjectURL(file)}.${file.name
|
||||
.split(".")
|
||||
.pop()}`;
|
||||
input.isLocal = true;
|
||||
}
|
||||
} else if (typeof result === "string") {
|
||||
// server returned a string (likely a URL)
|
||||
fm.data[field.name] = result;
|
||||
input.fase = "preview";
|
||||
input.preview = result;
|
||||
input.isLocal = false;
|
||||
} else if (Array.isArray(result) && result.length > 0) {
|
||||
// older behavior: server returned an array with path fragments
|
||||
fm.data[field.name] = `_file${get(result, "[0]")}`;
|
||||
input.fase = "preview";
|
||||
input.preview = `${URL.createObjectURL(file)}.${file.name
|
||||
.split(".")
|
||||
.pop()}`;
|
||||
input.isLocal = true;
|
||||
} else {
|
||||
// unknown shape: store raw result and show local preview
|
||||
fm.data[field.name] = result;
|
||||
input.fase = "preview";
|
||||
input.preview = `${URL.createObjectURL(file)}.${file.name
|
||||
.split(".")
|
||||
.pop()}`;
|
||||
input.isLocal = true;
|
||||
}
|
||||
|
||||
fm.render();
|
||||
input.render();
|
||||
|
||||
if (typeof on_change === "function") await on_change({});
|
||||
} catch (err: any) {
|
||||
console.error("Upload error", err);
|
||||
alert(`Upload failed: ${String(err?.message || err)}`);
|
||||
}
|
||||
} else {
|
||||
// Default: keep file locally in fm.data and preview
|
||||
fm.data[field.name] = file;
|
||||
// Default: keep file locally in fm.data and preview
|
||||
fm.data[field.name] = file;
|
||||
fm.render();
|
||||
input.fase = "preview";
|
||||
input.preview = `${URL.createObjectURL(file)}.${file.name
|
||||
.split(".")
|
||||
.pop()}`;
|
||||
input.isLocal = true;
|
||||
input.render();
|
||||
if (typeof on_change === "function") {
|
||||
await on_change({});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.ref) {
|
||||
input.ref.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-grow flex-row flex w-full h-full items-stretch relative">
|
||||
{input.fase === "start" ? (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
"hover:bg-gray-50 text-gray-900 text-md rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 ",
|
||||
css`
|
||||
input[type="file"],
|
||||
input[type="file"]::-webkit-file-upload-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
disabled && "bg-gray-50"
|
||||
)}
|
||||
>
|
||||
{!disabled && (
|
||||
<input
|
||||
ref={(ref) => {
|
||||
if (ref) input.ref = ref;
|
||||
}}
|
||||
type="file"
|
||||
multiple={false}
|
||||
// accept={field.prop.upload?.accept}
|
||||
accept={"file/**"}
|
||||
onChange={on_upload}
|
||||
className={cx(
|
||||
"absolute w-full h-full cursor-pointer top-0 left-0 opacity-0"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (input.ref) {
|
||||
input.ref.click();
|
||||
}
|
||||
}}
|
||||
className="items-center flex text-base px-1 outline-none rounded cursor-pointer flex-row justify-center"
|
||||
>
|
||||
<div className="flex flex-row items-center px-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-row items-center text-sm">
|
||||
Add File
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center px-1.5 text-sm">-</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : input.fase === "upload" ? (
|
||||
<div className="flex items-center">
|
||||
<div className="px-2">
|
||||
<Loader2 className={cx("h-5 w-5 animate-spin")} />
|
||||
</div>
|
||||
<div className="">Uploading</div>
|
||||
</div>
|
||||
) : input.fase === "preview" ? (
|
||||
<div className="flex flex-row gap-x-1 justify-between flex-1 p-1">
|
||||
<FilePreview
|
||||
url={input.isLocal ? input.preview : value || ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled ? (
|
||||
<>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (!disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm("Clear this file ?")) {
|
||||
input.fase = "start";
|
||||
fm.data[field.name] = null;
|
||||
fm.render();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center border px-2 rounded cursor-pointer hover:bg-red-100"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="text-red-500 h-4 w-4" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const IconFile: FC<{ type: string }> = ({ type }) => {
|
||||
if (["xlsx"].includes(type)) {
|
||||
return (
|
||||
<div className="flex flex-row text-[#2a801d]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m2.859 2.877l12.57-1.795a.5.5 0 0 1 .571.494v20.848a.5.5 0 0 1-.57.494L2.858 21.123a1 1 0 0 1-.859-.99V3.867a1 1 0 0 1 .859-.99M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4zm-6.8 9L13 8h-2.4L9 10.286L7.4 8H5l2.8 4L5 16h2.4L9 13.714L10.6 16H13z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex flex-row ">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,837 @@
|
|||
import {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { TypeaheadOptions } from "./typeahead-opt";
|
||||
import { Badge } from "../../ui/badge";
|
||||
import uniqBy from "lodash.uniqby";
|
||||
import { IconChevronDown, IconX } from "@tabler/icons-react";
|
||||
|
||||
type OptItem = { value: string; label: string; tag?: string };
|
||||
|
||||
export const Typeahead: FC<{
|
||||
fitur?: "search-add";
|
||||
onLabel?: string | ((item: any) => string);
|
||||
value?: string[] | null;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: (arg: {
|
||||
search: string;
|
||||
existing: OptItem[];
|
||||
paging?: number;
|
||||
take?: number;
|
||||
}) => (string | OptItem)[] | Promise<(string | OptItem)[]>;
|
||||
onSelect?: (arg: { search: string; item?: null | OptItem }) => string | false;
|
||||
onChange?: (selected: string[]) => void;
|
||||
unique?: boolean;
|
||||
allowNew?: boolean;
|
||||
className?: string;
|
||||
popupClassName?: string;
|
||||
localSearch?: boolean;
|
||||
autoPopupWidth?: boolean;
|
||||
focusOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
mode?: "multi" | "single";
|
||||
note?: string;
|
||||
disabledSearch?: boolean;
|
||||
onInit?: (e: any) => void;
|
||||
isBetter?: boolean;
|
||||
}> = ({
|
||||
value,
|
||||
fitur,
|
||||
note,
|
||||
options: options_fn,
|
||||
onSelect,
|
||||
unique,
|
||||
allowNew: allow_new,
|
||||
focusOpen: on_focus_open,
|
||||
localSearch: local_search,
|
||||
autoPopupWidth: auto_popup_width,
|
||||
placeholder,
|
||||
mode,
|
||||
disabled,
|
||||
onChange,
|
||||
className,
|
||||
popupClassName,
|
||||
disabledSearch,
|
||||
onInit,
|
||||
isBetter = false,
|
||||
}) => {
|
||||
const maxLength = 4;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedTerm, setDebouncedTerm] = useState("");
|
||||
const local = useLocal({
|
||||
value: [] as string[],
|
||||
open: false,
|
||||
options: [] as OptItem[],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
selectBetter: {
|
||||
all: false,
|
||||
partial: [] as any[],
|
||||
},
|
||||
search: {
|
||||
input: "",
|
||||
timeout: null as any,
|
||||
searching: false,
|
||||
promise: null as any,
|
||||
result: null as null | OptItem[],
|
||||
},
|
||||
unique: typeof unique === "undefined" ? true : unique,
|
||||
allow_new: typeof allow_new === "undefined" ? false : allow_new,
|
||||
on_focus_open: typeof on_focus_open === "undefined" ? true : on_focus_open,
|
||||
local_search: typeof local_search === "undefined" ? true : local_search,
|
||||
mode: typeof mode === "undefined" ? "multi" : mode,
|
||||
auto_popup_width:
|
||||
typeof auto_popup_width === "undefined" ? false : auto_popup_width,
|
||||
select: null as null | OptItem,
|
||||
});
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
let select_found = false;
|
||||
let options = [...(local.search.result || local.options)];
|
||||
|
||||
const added = new Set<string>();
|
||||
if (local.mode === "multi") {
|
||||
options = options.filter((e) => {
|
||||
if (!added.has(e.value)) added.add(e.value);
|
||||
else return false;
|
||||
if (local.select && local.select.value === e.value) select_found = true;
|
||||
if (local.unique) {
|
||||
if (local.value.includes(e.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (Array.isArray(value) && value?.length) {
|
||||
if (!select_found) {
|
||||
local.select = options[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
if (options.length === 0) {
|
||||
loadOptions().then(() => {
|
||||
if (typeof value === "object" && value) {
|
||||
local.value = value;
|
||||
local.render();
|
||||
} else if (typeof value === "string") {
|
||||
local.value = [value];
|
||||
local.render();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (typeof value === "object" && value) {
|
||||
local.value = value;
|
||||
local.render();
|
||||
} else {
|
||||
local.value = [];
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const select = useCallback(
|
||||
(arg: { search: string; item?: null | OptItem }) => {
|
||||
if (!local.allow_new) {
|
||||
let found = null;
|
||||
if (!arg.item) {
|
||||
found = options.find((e) => e.value === arg.search);
|
||||
} else {
|
||||
found = options.find((e) => e.value === arg.item?.value);
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (local.unique) {
|
||||
let found = local.value.find((e) => {
|
||||
return e === arg.item?.value || arg.search === e;
|
||||
});
|
||||
if (found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (local.mode === "single") {
|
||||
local.value = [];
|
||||
}
|
||||
|
||||
if (typeof onSelect === "function") {
|
||||
const result = onSelect(arg);
|
||||
|
||||
if (result) {
|
||||
local.value.push(result);
|
||||
local.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
let val = false as any;
|
||||
if (arg.item) {
|
||||
local.value.push(arg.item.value);
|
||||
val = arg.item.value;
|
||||
} else {
|
||||
if (!arg.search) return false;
|
||||
local.value.push(arg.search);
|
||||
val = arg.search;
|
||||
}
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
local.render();
|
||||
return val;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[onSelect, local.value, options]
|
||||
);
|
||||
|
||||
const keydown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace") {
|
||||
if (local.value.length > 0 && e.currentTarget.selectionStart === 0) {
|
||||
local.value.pop();
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selected = select({
|
||||
search: local.search.input,
|
||||
item: local.select,
|
||||
});
|
||||
|
||||
if (local.mode === "single") {
|
||||
local.open = false;
|
||||
}
|
||||
if (typeof selected === "string") {
|
||||
if (!allow_new) resetSearch();
|
||||
if (local.mode === "single") {
|
||||
const item = options.find((item) => item.value === selected);
|
||||
if (item) {
|
||||
local.search.input = item.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
local.render();
|
||||
return;
|
||||
}
|
||||
if (options.length > 0) {
|
||||
local.open = true;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const idx = options.findIndex((item) => {
|
||||
if (item.value === local.select?.value) return true;
|
||||
});
|
||||
if (idx >= 0) {
|
||||
if (idx + 1 <= options.length - 1) {
|
||||
local.select = options[idx + 1];
|
||||
} else {
|
||||
local.select = options[0];
|
||||
}
|
||||
} else {
|
||||
local.select = options[0];
|
||||
}
|
||||
local.render();
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const idx = options.findIndex((item) => {
|
||||
if (item.value === local.select?.value) return true;
|
||||
});
|
||||
if (idx >= 0) {
|
||||
if (idx - 1 >= 0) {
|
||||
local.select = options[idx - 1];
|
||||
} else {
|
||||
local.select = options[options.length - 1];
|
||||
}
|
||||
} else {
|
||||
local.select = options[0];
|
||||
}
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
},
|
||||
[local.value, local.select, select, options, local.search.input]
|
||||
);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
if (typeof options_fn === "function" && !local.loading) {
|
||||
local.loading = true;
|
||||
local.loaded = false;
|
||||
local.render();
|
||||
const res = options_fn({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
const applyOptions = (result: (string | OptItem)[]) => {
|
||||
local.options = result.map((item) => {
|
||||
if (typeof item === "string") return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.render();
|
||||
};
|
||||
|
||||
if (res instanceof Promise) {
|
||||
const result = await res;
|
||||
applyOptions(result);
|
||||
} else {
|
||||
applyOptions(res);
|
||||
}
|
||||
local.loaded = true;
|
||||
local.loading = false;
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
}, [options_fn]);
|
||||
useEffect(() => {
|
||||
if (typeof onInit === "function") {
|
||||
onInit({
|
||||
reload: async () => {
|
||||
if (typeof options_fn === "function" && !local.loading) {
|
||||
local.loading = true;
|
||||
local.loaded = false;
|
||||
local.render();
|
||||
const res = options_fn({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
const applyOptions = (result: (string | OptItem)[]) => {
|
||||
local.options = result.map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.render();
|
||||
};
|
||||
|
||||
if (res instanceof Promise) {
|
||||
const result = await res;
|
||||
applyOptions(result);
|
||||
} else {
|
||||
applyOptions(res);
|
||||
}
|
||||
local.loaded = true;
|
||||
local.loading = false;
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
// Debounce effect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedTerm(searchTerm); // Update debounced term after 1 second
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer); // Clear timeout if user types again
|
||||
}, [searchTerm]);
|
||||
|
||||
// Function to handle search
|
||||
useEffect(() => {
|
||||
if (debouncedTerm) {
|
||||
performSearch(debouncedTerm);
|
||||
}
|
||||
}, [debouncedTerm]);
|
||||
|
||||
const performSearch = (value: any) => {
|
||||
if (typeof onSelect === "function") {
|
||||
const result = onSelect({
|
||||
search: value,
|
||||
item: {
|
||||
label: value,
|
||||
value: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
local.value.push(result);
|
||||
local.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Lakukan pencarian, panggil API, atau filter data di sini
|
||||
};
|
||||
const resetSearch = () => {
|
||||
local.search.searching = false;
|
||||
local.search.input = "";
|
||||
local.search.promise = null;
|
||||
local.search.result = null;
|
||||
local.select = null;
|
||||
clearTimeout(local.search.timeout);
|
||||
};
|
||||
|
||||
if (local.mode === "single" && local.value.length > 1) {
|
||||
local.value = [local.value.pop() || ""];
|
||||
}
|
||||
|
||||
if (local.value.length === 0) {
|
||||
if (local.mode === "single") {
|
||||
if (!local.open && !allow_new) {
|
||||
local.select = null;
|
||||
|
||||
local.search.input = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const valueLabel = uniqBy(
|
||||
local.value?.map((value) => {
|
||||
if (local.mode === "single") {
|
||||
const item = options.find((item) => item.value === value);
|
||||
|
||||
if (!local.open && !allow_new) {
|
||||
local.select = item || null;
|
||||
|
||||
local.search.input = item?.tag || item?.label || "";
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
const item = local.options.find((e) => e.value === value);
|
||||
return item;
|
||||
}),
|
||||
"value"
|
||||
);
|
||||
let inputval = local.search.input;
|
||||
|
||||
if (!local.open && local.mode === "single" && local.value?.length > 0) {
|
||||
const found = options.find((e) => e.value === local.value[0]);
|
||||
if (found) {
|
||||
inputval = found.tag || found.label;
|
||||
} else {
|
||||
inputval = local.value[0];
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (allow_new && local.open) {
|
||||
local.search.input = local.value[0];
|
||||
local.render();
|
||||
}
|
||||
}, [local.open]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow w-full relative">
|
||||
<div
|
||||
className={cx(
|
||||
allow_new
|
||||
? "cursor-text"
|
||||
: local.mode === "single"
|
||||
? "cursor-pointer"
|
||||
: "cursor-text",
|
||||
"text-black flex relative flex-wrap py-0 items-center w-full h-full flex-1 ",
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) input.current?.focus();
|
||||
}}
|
||||
>
|
||||
{local.mode === "multi" ? (
|
||||
<div
|
||||
className={cx(
|
||||
css`
|
||||
margin-top: 5px;
|
||||
margin-bottom: -3px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{valueLabel.map((e, idx) => {
|
||||
return (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant={"outline"}
|
||||
className={cx(
|
||||
"space-x-1 mr-2 mb-2 bg-white",
|
||||
!disabled &&
|
||||
" cursor-pointer hover:bg-red-100 hover:border-red-100"
|
||||
)}
|
||||
onClick={(ev) => {
|
||||
if (!disabled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
local.value = local.value.filter(
|
||||
(val) => e?.value !== val
|
||||
);
|
||||
local.render();
|
||||
input.current?.focus();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-xs">
|
||||
{e?.tag || e?.label || <> </>}
|
||||
</div>
|
||||
{!disabled && <IconX size={12} />}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<TypeaheadOptions
|
||||
fitur={fitur}
|
||||
popup={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
local.select = null;
|
||||
}
|
||||
local.open = open;
|
||||
local.render();
|
||||
|
||||
if (!open) {
|
||||
resetSearch();
|
||||
}
|
||||
}}
|
||||
onRemove={(data) => {
|
||||
local.value = local.value.filter((val) => data?.value !== val);
|
||||
local.render();
|
||||
input.current?.focus();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
}}
|
||||
onSelectAll={(data: boolean) => {
|
||||
local.value = data ? options.map((e) => e?.value) : [];
|
||||
local.render();
|
||||
input.current?.focus();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
}}
|
||||
init={local}
|
||||
isBetter={isBetter}
|
||||
loading={local.loading}
|
||||
showEmpty={!allow_new}
|
||||
className={popupClassName}
|
||||
open={local.open}
|
||||
options={options}
|
||||
searching={local.search.searching}
|
||||
searchText={local.search.input}
|
||||
onSearch={async (e) => {
|
||||
const val = e.currentTarget.value;
|
||||
if (!local.open) {
|
||||
local.open = true;
|
||||
}
|
||||
|
||||
local.search.input = val;
|
||||
local.render();
|
||||
|
||||
if (local.search.promise) {
|
||||
await local.search.promise;
|
||||
}
|
||||
|
||||
local.search.searching = true;
|
||||
local.render();
|
||||
if (allow_new) {
|
||||
setSearchTerm(val);
|
||||
}
|
||||
if (local.search.searching) {
|
||||
if (local.local_search) {
|
||||
if (!local.loaded) {
|
||||
await loadOptions();
|
||||
}
|
||||
const search = local.search.input.toLowerCase();
|
||||
if (search) {
|
||||
local.search.result = options.filter((e) =>
|
||||
e.label.toLowerCase().includes(search)
|
||||
);
|
||||
|
||||
if (
|
||||
local.search.result.length > 0 &&
|
||||
!local.search.result.find(
|
||||
(e) => e.value === local.select?.value
|
||||
)
|
||||
) {
|
||||
}
|
||||
} else {
|
||||
local.search.result = null;
|
||||
}
|
||||
local.search.searching = false;
|
||||
local.render();
|
||||
} else {
|
||||
clearTimeout(local.search.timeout);
|
||||
local.search.timeout = setTimeout(async () => {
|
||||
const result = options_fn?.({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
if (result) {
|
||||
if (result instanceof Promise) {
|
||||
local.search.promise = result;
|
||||
local.search.result = (await result).map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.search.searching = false;
|
||||
local.search.promise = null;
|
||||
} else {
|
||||
local.search.result = result.map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.search.searching = false;
|
||||
}
|
||||
|
||||
if (
|
||||
local.search.result.length > 0 &&
|
||||
!local.search.result.find(
|
||||
(e) => e.value === local.select?.value
|
||||
)
|
||||
) {
|
||||
}
|
||||
|
||||
local.render();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSelect={(value) => {
|
||||
if (!isBetter) local.open = false;
|
||||
resetSearch();
|
||||
const item = options.find((item) => item.value === value);
|
||||
if (item) {
|
||||
let search = local.search.input;
|
||||
if (local.mode === "single") {
|
||||
local.search.input = item.tag || item.label;
|
||||
} else {
|
||||
local.search.input = "";
|
||||
}
|
||||
|
||||
select({
|
||||
search,
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
local.render();
|
||||
}}
|
||||
width={
|
||||
local.auto_popup_width ? input.current?.offsetWidth : undefined
|
||||
}
|
||||
isMulti={local.mode === "multi"}
|
||||
selected={({ item, options, idx }) => {
|
||||
// console.log(local.select);
|
||||
if (isBetter) {
|
||||
const val = local.value?.length ? local.value : [];
|
||||
let isSelect = options.find((e) => {
|
||||
return (
|
||||
e?.value === item?.value &&
|
||||
val.find((ex) => ex === item?.value)
|
||||
);
|
||||
});
|
||||
return isSelect ? true : false;
|
||||
} else if (item.value === local.select?.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
allow_new ? "cursor-text" : "cursor-pointer",
|
||||
"single flex-1 flex-grow flex flex-row"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
if (!local.open) {
|
||||
if (local.on_focus_open) {
|
||||
loadOptions();
|
||||
local.open = true;
|
||||
local.render();
|
||||
// if (allow_new) {
|
||||
// local.search.input = inputval;
|
||||
// local.render();
|
||||
// }
|
||||
}
|
||||
}
|
||||
if (local.mode === "single") {
|
||||
if (input && input.current) input.current.select();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isBetter ? (
|
||||
<div className="h-9 flex-grow flex flex-row items-start">
|
||||
<div className="flex flex-grow"></div>
|
||||
<div className="h-9 flex flex-row items-center px-2">
|
||||
<IconChevronDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
placeholder={
|
||||
local.mode === "multi"
|
||||
? placeholder
|
||||
: valueLabel[0]?.label || placeholder
|
||||
}
|
||||
type="text"
|
||||
ref={input}
|
||||
value={inputval}
|
||||
onChange={async (e) => {
|
||||
const val = e.currentTarget.value;
|
||||
if (!local.open) {
|
||||
local.open = true;
|
||||
}
|
||||
|
||||
local.search.input = val;
|
||||
local.render();
|
||||
|
||||
if (local.search.promise) {
|
||||
await local.search.promise;
|
||||
}
|
||||
|
||||
local.search.searching = true;
|
||||
local.render();
|
||||
if (allow_new) {
|
||||
setSearchTerm(val);
|
||||
}
|
||||
if (local.search.searching) {
|
||||
if (local.local_search) {
|
||||
if (!local.loaded) {
|
||||
await loadOptions();
|
||||
}
|
||||
const search = local.search.input.toLowerCase();
|
||||
if (search) {
|
||||
local.search.result = options.filter((e) =>
|
||||
e.label.toLowerCase().includes(search)
|
||||
);
|
||||
|
||||
if (
|
||||
local.search.result.length > 0 &&
|
||||
!local.search.result.find(
|
||||
(e) => e.value === local.select?.value
|
||||
)
|
||||
) {
|
||||
local.select = local.search.result[0];
|
||||
}
|
||||
} else {
|
||||
local.search.result = null;
|
||||
}
|
||||
local.search.searching = false;
|
||||
local.render();
|
||||
} else {
|
||||
clearTimeout(local.search.timeout);
|
||||
local.search.timeout = setTimeout(async () => {
|
||||
const result = options_fn?.({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
if (result) {
|
||||
if (result instanceof Promise) {
|
||||
local.search.promise = result;
|
||||
local.search.result = (await result).map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.search.searching = false;
|
||||
local.search.promise = null;
|
||||
} else {
|
||||
local.search.result = result.map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.search.searching = false;
|
||||
}
|
||||
|
||||
if (
|
||||
local.search.result.length > 0 &&
|
||||
!local.search.result.find(
|
||||
(e) => e.value === local.select?.value
|
||||
)
|
||||
) {
|
||||
local.select = local.search.result[0];
|
||||
}
|
||||
|
||||
local.render();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!disabled ? disabledSearch : disabled}
|
||||
spellCheck={false}
|
||||
className={cx(
|
||||
"text-black flex h-9 w-full border-input bg-transparent px-3 py-1 text-base border-none shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground md:text-sm focus:outline-none focus:ring-0",
|
||||
local.mode === "single" ? "cursor-pointer" : ""
|
||||
)}
|
||||
style={{
|
||||
pointerEvents: disabledSearch ? "none" : "auto", // Mencegah input menangkap klik saat disabled
|
||||
}}
|
||||
onKeyDown={keydown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TypeaheadOptions>
|
||||
</div>
|
||||
|
||||
{local.mode === "single" && fitur !== "search-add" && (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
"typeahead-arrow absolute z-10 inset-0 left-auto flex items-center ",
|
||||
" justify-center w-6 mr-1 my-2 bg-transparant",
|
||||
disabled ? "hidden" : "cursor-pointer"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
local.value = [];
|
||||
local.render();
|
||||
if (typeof onChange === "function") onChange(local.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{inputval ? <IconX size={14} /> : <IconChevronDown size={14} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
import {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { Badge } from "../../ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import { TypeaheadOptionsBetter } from "./typeahead-opt-better";
|
||||
import { IconChevronDown, IconX } from "@tabler/icons-react";
|
||||
|
||||
type OptItem = { value: string; label: string; tag?: string };
|
||||
|
||||
export const TypeaheadBetter: FC<{
|
||||
fitur?: "search-add";
|
||||
onLoad?: any[] | ((param: any) => Promise<any[]> | any[]);
|
||||
onLabel?: string | ((item: any) => string);
|
||||
value?: string[] | null;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: (arg: {
|
||||
search: string;
|
||||
existing: OptItem[];
|
||||
paging?: number;
|
||||
take?: number;
|
||||
}) => (string | OptItem)[] | Promise<(string | OptItem)[]>;
|
||||
onSelect?: (arg: { search: string; item?: null | OptItem }) => string | false;
|
||||
onChange?: (selected: string[]) => void;
|
||||
unique?: boolean;
|
||||
allowNew?: boolean;
|
||||
className?: string;
|
||||
popupClassName?: string;
|
||||
localSearch?: boolean;
|
||||
autoPopupWidth?: boolean;
|
||||
focusOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
mode?: "multi" | "single";
|
||||
note?: string;
|
||||
disabledSearch?: boolean;
|
||||
onInit?: (e: any) => void;
|
||||
isBetter?: boolean;
|
||||
onCount?: any | ((param: any) => Promise<any> | any);
|
||||
}> = ({
|
||||
value,
|
||||
fitur,
|
||||
note,
|
||||
options: options_fn,
|
||||
onSelect,
|
||||
unique,
|
||||
onLabel,
|
||||
allowNew: allow_new,
|
||||
focusOpen: on_focus_open,
|
||||
localSearch: local_search,
|
||||
autoPopupWidth: auto_popup_width,
|
||||
placeholder,
|
||||
mode,
|
||||
disabled,
|
||||
onChange,
|
||||
className,
|
||||
popupClassName,
|
||||
disabledSearch,
|
||||
onInit,
|
||||
onLoad,
|
||||
isBetter = false,
|
||||
onCount,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedTerm, setDebouncedTerm] = useState("");
|
||||
const local = useLocal({
|
||||
value: [] as string[],
|
||||
open: false,
|
||||
options: [] as OptItem[],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
selectBetter: {
|
||||
all: false,
|
||||
partial: [] as any[],
|
||||
},
|
||||
search: {
|
||||
input: "",
|
||||
timeout: null as any,
|
||||
searching: false,
|
||||
promise: null as any,
|
||||
result: null as null | OptItem[],
|
||||
},
|
||||
unique: typeof unique === "undefined" ? true : unique,
|
||||
allow_new: typeof allow_new === "undefined" ? false : allow_new,
|
||||
on_focus_open: typeof on_focus_open === "undefined" ? true : on_focus_open,
|
||||
local_search: typeof local_search === "undefined" ? true : local_search,
|
||||
mode: typeof mode === "undefined" ? "multi" : mode,
|
||||
auto_popup_width:
|
||||
typeof auto_popup_width === "undefined" ? false : auto_popup_width,
|
||||
select: null as null | OptItem,
|
||||
});
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
let select_found = false;
|
||||
let options = [...(local.search.result || local.options)];
|
||||
|
||||
const added = new Set<string>();
|
||||
if (local.mode === "multi") {
|
||||
options = options.filter((e) => {
|
||||
if (!added.has(e.value)) added.add(e.value);
|
||||
else return false;
|
||||
if (local.select && local.select.value === e.value) select_found = true;
|
||||
if (local.unique) {
|
||||
if (local.value.includes(e.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (Array.isArray(value) && value?.length) {
|
||||
if (!select_found) {
|
||||
local.select = options[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
if (options.length === 0) {
|
||||
loadOptions().then(() => {
|
||||
if (typeof value === "object" && value) {
|
||||
local.value = value;
|
||||
local.render();
|
||||
} else if (typeof value === "string") {
|
||||
local.value = [value];
|
||||
local.render();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (typeof value === "object" && value) {
|
||||
local.value = value;
|
||||
local.render();
|
||||
} else {
|
||||
local.value = [];
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const select = useCallback(
|
||||
(arg: { search: string; item?: null | OptItem }) => {
|
||||
if (!local.allow_new) {
|
||||
let found = null;
|
||||
if (!arg.item) {
|
||||
found = options.find((e) => e.value === arg.search);
|
||||
} else {
|
||||
found = options.find((e) => e.value === arg.item?.value);
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (local.unique) {
|
||||
let found = local.value.find((e) => {
|
||||
return e === arg.item?.value || arg.search === e;
|
||||
});
|
||||
if (found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (local.mode === "single") {
|
||||
local.value = [];
|
||||
}
|
||||
|
||||
if (typeof onSelect === "function") {
|
||||
const result = onSelect(arg);
|
||||
|
||||
if (result) {
|
||||
local.value.push(result);
|
||||
local.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
let val = false as any;
|
||||
if (arg.item) {
|
||||
local.value.push(arg.item.value);
|
||||
val = arg.item.value;
|
||||
} else {
|
||||
if (!arg.search) return false;
|
||||
local.value.push(arg.search);
|
||||
val = arg.search;
|
||||
}
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
local.render();
|
||||
return val;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[onSelect, local.value, options]
|
||||
);
|
||||
|
||||
const keydown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace") {
|
||||
if (local.value.length > 0 && e.currentTarget.selectionStart === 0) {
|
||||
local.value.pop();
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selected = select({
|
||||
search: local.search.input,
|
||||
item: local.select,
|
||||
});
|
||||
|
||||
if (local.mode === "single") {
|
||||
local.open = false;
|
||||
}
|
||||
if (typeof selected === "string") {
|
||||
if (!allow_new) resetSearch();
|
||||
if (local.mode === "single") {
|
||||
const item = options.find((item) => item.value === selected);
|
||||
if (item) {
|
||||
local.search.input = item.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
local.render();
|
||||
return;
|
||||
}
|
||||
if (options.length > 0) {
|
||||
local.open = true;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const idx = options.findIndex((item) => {
|
||||
if (item.value === local.select?.value) return true;
|
||||
});
|
||||
if (idx >= 0) {
|
||||
if (idx + 1 <= options.length - 1) {
|
||||
local.select = options[idx + 1];
|
||||
} else {
|
||||
local.select = options[0];
|
||||
}
|
||||
} else {
|
||||
local.select = options[0];
|
||||
}
|
||||
local.render();
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const idx = options.findIndex((item) => {
|
||||
if (item.value === local.select?.value) return true;
|
||||
});
|
||||
if (idx >= 0) {
|
||||
if (idx - 1 >= 0) {
|
||||
local.select = options[idx - 1];
|
||||
} else {
|
||||
local.select = options[options.length - 1];
|
||||
}
|
||||
} else {
|
||||
local.select = options[0];
|
||||
}
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
},
|
||||
[local.value, local.select, select, options, local.search.input]
|
||||
);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
if (typeof options_fn === "function" && !local.loading) {
|
||||
local.loading = true;
|
||||
local.loaded = false;
|
||||
local.render();
|
||||
const res = options_fn({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
const applyOptions = (result: (string | OptItem)[]) => {
|
||||
local.options = result.map((item) => {
|
||||
if (typeof item === "string") return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.render();
|
||||
};
|
||||
|
||||
if (res instanceof Promise) {
|
||||
const result = await res;
|
||||
applyOptions(result);
|
||||
} else {
|
||||
applyOptions(res);
|
||||
}
|
||||
local.loaded = true;
|
||||
local.loading = false;
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
}, [options_fn]);
|
||||
useEffect(() => {
|
||||
if (typeof onInit === "function") {
|
||||
onInit({
|
||||
reload: async () => {
|
||||
if (typeof options_fn === "function" && !local.loading) {
|
||||
local.loading = true;
|
||||
local.loaded = false;
|
||||
local.render();
|
||||
const res = options_fn({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
const applyOptions = (result: (string | OptItem)[]) => {
|
||||
local.options = result.map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.render();
|
||||
};
|
||||
|
||||
if (res instanceof Promise) {
|
||||
const result = await res;
|
||||
applyOptions(result);
|
||||
} else {
|
||||
applyOptions(res);
|
||||
}
|
||||
local.loaded = true;
|
||||
local.loading = false;
|
||||
local.render();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
// Debounce effect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedTerm(searchTerm); // Update debounced term after 1 second
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer); // Clear timeout if user types again
|
||||
}, [searchTerm]);
|
||||
|
||||
// Function to handle search
|
||||
useEffect(() => {
|
||||
if (debouncedTerm) {
|
||||
performSearch(debouncedTerm);
|
||||
}
|
||||
}, [debouncedTerm]);
|
||||
|
||||
const performSearch = (value: any) => {
|
||||
if (typeof onSelect === "function") {
|
||||
const result = onSelect({
|
||||
search: value,
|
||||
item: {
|
||||
label: value,
|
||||
value: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
local.value.push(result);
|
||||
local.render();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Lakukan pencarian, panggil API, atau filter data di sini
|
||||
};
|
||||
const resetSearch = () => {
|
||||
local.search.searching = false;
|
||||
local.search.input = "";
|
||||
local.search.promise = null;
|
||||
local.search.result = null;
|
||||
local.select = null;
|
||||
clearTimeout(local.search.timeout);
|
||||
};
|
||||
|
||||
if (local.mode === "single" && local.value.length > 1) {
|
||||
local.value = [local.value.pop() || ""];
|
||||
}
|
||||
|
||||
if (local.value.length === 0) {
|
||||
if (local.mode === "single") {
|
||||
if (!local.open && !allow_new) {
|
||||
local.select = null;
|
||||
|
||||
local.search.input = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const valueLabel = [{ id: 1, name: "LABEL" }];
|
||||
let inputval = local.search.input;
|
||||
|
||||
if (!local.open && local.mode === "single" && local.value?.length > 0) {
|
||||
const found = options.find((e) => e.value === local.value[0]);
|
||||
if (found) {
|
||||
inputval = found.tag || found.label;
|
||||
} else {
|
||||
inputval = local.value[0];
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (allow_new && local.open) {
|
||||
local.search.input = local.value[0];
|
||||
local.render();
|
||||
}
|
||||
}, [local.open]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-grow w-full relative">
|
||||
<div
|
||||
className={cx(
|
||||
allow_new
|
||||
? "cursor-text"
|
||||
: local.mode === "single"
|
||||
? "cursor-pointer"
|
||||
: "cursor-text",
|
||||
"text-black flex relative flex-wrap py-0 items-center w-full h-full flex-1 ",
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) input.current?.focus();
|
||||
}}
|
||||
>
|
||||
{local.mode === "multi" ? (
|
||||
<div
|
||||
className={cx(
|
||||
"gap-2 px-1",
|
||||
css`
|
||||
margin-top: 5px;
|
||||
margin-bottom: -3px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{valueLabel.map((e: any, idx) => {
|
||||
const label =
|
||||
typeof onLabel === "function"
|
||||
? onLabel(e)
|
||||
: typeof onLabel === "string"
|
||||
? e[onLabel]
|
||||
: "";
|
||||
return (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant={"outline"}
|
||||
className={cx(
|
||||
"space-x-1 mr-2 mb-2 bg-white",
|
||||
!disabled &&
|
||||
" cursor-pointer hover:bg-red-100 hover:border-red-100"
|
||||
)}
|
||||
onClick={(ev) => {
|
||||
// if (!disabled) {
|
||||
// ev.stopPropagation();
|
||||
// ev.preventDefault();
|
||||
// local.value = local.value.filter(
|
||||
// (val) => e?.value !== val
|
||||
// );
|
||||
// local.render();
|
||||
// input.current?.focus();
|
||||
// if (typeof onChange === "function") {
|
||||
// onChange(local.value);
|
||||
// }
|
||||
// }
|
||||
}}
|
||||
>
|
||||
<div className="text-xs">{label || <> </>}</div>
|
||||
{!disabled && <IconX size={12} />}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<TypeaheadOptionsBetter
|
||||
fitur={fitur}
|
||||
popup={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
local.select = null;
|
||||
}
|
||||
local.open = open;
|
||||
local.render();
|
||||
|
||||
if (!open) {
|
||||
resetSearch();
|
||||
}
|
||||
}}
|
||||
onRemove={(data) => {
|
||||
local.value = local.value.filter((val) => data?.value !== val);
|
||||
local.render();
|
||||
input.current?.focus();
|
||||
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
}}
|
||||
onSelectAll={(data: boolean) => {
|
||||
local.value = data ? options.map((e) => e?.value) : [];
|
||||
local.render();
|
||||
input.current?.focus();
|
||||
if (typeof onChange === "function") {
|
||||
onChange(local.value);
|
||||
}
|
||||
}}
|
||||
init={local}
|
||||
isBetter={isBetter}
|
||||
loading={local.loading}
|
||||
showEmpty={!allow_new}
|
||||
className={popupClassName}
|
||||
open={local.open}
|
||||
options={options}
|
||||
searching={local.search.searching}
|
||||
searchText={local.search.input}
|
||||
onLabel={onLabel}
|
||||
onSearch={async (e) => {
|
||||
const val = e.currentTarget.value;
|
||||
if (!local.open) {
|
||||
local.open = true;
|
||||
}
|
||||
|
||||
local.search.input = val;
|
||||
local.render();
|
||||
|
||||
if (local.search.promise) {
|
||||
await local.search.promise;
|
||||
}
|
||||
|
||||
local.search.searching = true;
|
||||
local.render();
|
||||
if (allow_new) {
|
||||
setSearchTerm(val);
|
||||
}
|
||||
if (local.search.searching) {
|
||||
if (local.local_search) {
|
||||
if (!local.loaded) {
|
||||
await loadOptions();
|
||||
}
|
||||
const search = local.search.input.toLowerCase();
|
||||
if (search) {
|
||||
local.search.result = options.filter((e) =>
|
||||
e.label.toLowerCase().includes(search)
|
||||
);
|
||||
|
||||
if (
|
||||
local.search.result.length > 0 &&
|
||||
!local.search.result.find(
|
||||
(e) => e.value === local.select?.value
|
||||
)
|
||||
) {
|
||||
}
|
||||
} else {
|
||||
local.search.result = null;
|
||||
}
|
||||
local.search.searching = false;
|
||||
local.render();
|
||||
} else {
|
||||
clearTimeout(local.search.timeout);
|
||||
local.search.timeout = setTimeout(async () => {
|
||||
const result = options_fn?.({
|
||||
search: local.search.input,
|
||||
existing: options,
|
||||
});
|
||||
if (result) {
|
||||
if (result instanceof Promise) {
|
||||
local.search.promise = result;
|
||||
local.search.result = (await result).map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.search.searching = false;
|
||||
local.search.promise = null;
|
||||
} else {
|
||||
local.search.result = result.map((item) => {
|
||||
if (typeof item === "string")
|
||||
return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
local.search.searching = false;
|
||||
}
|
||||
|
||||
if (
|
||||
local.search.result.length > 0 &&
|
||||
!local.search.result.find(
|
||||
(e) => e.value === local.select?.value
|
||||
)
|
||||
) {
|
||||
}
|
||||
|
||||
local.render();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSelect={(value) => {
|
||||
if (!isBetter) local.open = false;
|
||||
resetSearch();
|
||||
const item = options.find((item) => item.value === value);
|
||||
if (item) {
|
||||
let search = local.search.input;
|
||||
if (local.mode === "single") {
|
||||
local.search.input = item.tag || item.label;
|
||||
} else {
|
||||
local.search.input = "";
|
||||
}
|
||||
|
||||
select({
|
||||
search,
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
local.render();
|
||||
}}
|
||||
width={
|
||||
local.auto_popup_width ? input.current?.offsetWidth : undefined
|
||||
}
|
||||
isMulti={local.mode === "multi"}
|
||||
selected={({ item, options, idx }) => {
|
||||
// console.log(local.select);
|
||||
if (isBetter) {
|
||||
const val = local.value?.length ? local.value : [];
|
||||
let isSelect = options.find((e) => {
|
||||
return (
|
||||
e?.value === item?.value &&
|
||||
val.find((ex) => ex === item?.value)
|
||||
);
|
||||
});
|
||||
return isSelect ? true : false;
|
||||
} else if (item.value === local.select?.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}}
|
||||
onLoad={onLoad}
|
||||
onCount={onCount}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
allow_new ? "cursor-text" : "cursor-pointer",
|
||||
"single flex-1 flex-grow flex flex-row"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
if (!local.open) {
|
||||
if (local.on_focus_open) {
|
||||
loadOptions();
|
||||
local.open = true;
|
||||
local.render();
|
||||
// if (allow_new) {
|
||||
// local.search.input = inputval;
|
||||
// local.render();
|
||||
// }
|
||||
}
|
||||
}
|
||||
if (local.mode === "single") {
|
||||
if (input && input.current) input.current.select();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-9 flex-grow flex flex-row items-start">
|
||||
<div className="flex flex-grow"></div>
|
||||
<div className="h-9 flex flex-row items-center px-2">
|
||||
<IconChevronDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TypeaheadOptionsBetter>
|
||||
</div>
|
||||
|
||||
{local.mode === "single" && fitur !== "search-add" && (
|
||||
<>
|
||||
<div
|
||||
className={cx(
|
||||
"typeahead-arrow absolute z-10 inset-0 left-auto flex items-center ",
|
||||
" justify-center w-6 mr-1 my-2 bg-transparant",
|
||||
disabled ? "hidden" : "cursor-pointer"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
local.value = [];
|
||||
local.render();
|
||||
if (typeof onChange === "function") onChange(local.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{inputval ? <X size={14} /> : <IconChevronDown size={14} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
import { FC, useCallback } from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { Popover } from "../../Popover/Popover";
|
||||
import { ButtonBetter } from "../../ui/button";
|
||||
import { Checkbox } from "../../ui/checkbox";
|
||||
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||
import { ListUIClean } from "../../list/ListUIClean";
|
||||
import { debouncedHandler } from "@/lib/utils/debounceHandler";
|
||||
|
||||
export type OptionItem = { value: string; label: string };
|
||||
export const TypeaheadOptionsBetter: FC<{
|
||||
popup?: boolean;
|
||||
onLabel?: string | ((item: any) => string);
|
||||
onRemove?: (data: any) => void;
|
||||
onSelectAll?: (data: boolean) => void;
|
||||
init?: any;
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
children: any;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
options: OptionItem[];
|
||||
className?: string;
|
||||
showEmpty: boolean;
|
||||
selected?: (arg: {
|
||||
item: OptionItem;
|
||||
options: OptionItem[];
|
||||
idx: number;
|
||||
}) => boolean;
|
||||
onSelect?: (value: string) => void;
|
||||
searching?: boolean;
|
||||
searchText?: string;
|
||||
width?: number;
|
||||
isMulti?: boolean;
|
||||
fitur?: "search-add";
|
||||
isBetter?: boolean;
|
||||
onSearch?: (event: any) => void;
|
||||
search?: boolean;
|
||||
onLoad?: any[] | ((param: any) => Promise<any[]> | any[]);
|
||||
onCount?: any | ((param: any) => Promise<any> | any);
|
||||
}> = ({
|
||||
popup,
|
||||
loading,
|
||||
children,
|
||||
onLabel,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
searching,
|
||||
searchText,
|
||||
showEmpty,
|
||||
width,
|
||||
isMulti,
|
||||
fitur,
|
||||
isBetter,
|
||||
init,
|
||||
onSearch,
|
||||
search,
|
||||
onRemove,
|
||||
onSelectAll,
|
||||
onLoad,
|
||||
onCount,
|
||||
}) => {
|
||||
if (!popup) return children;
|
||||
const local = useLocal({
|
||||
selectedIdx: 0,
|
||||
selected: [] as any[],
|
||||
search: "",
|
||||
list: null as any,
|
||||
});
|
||||
|
||||
const handleSearch = useCallback(
|
||||
debouncedHandler(() => {
|
||||
// local.refresh();
|
||||
console.log(local.list);
|
||||
}, 1000),
|
||||
[]
|
||||
);
|
||||
let content = (
|
||||
<div
|
||||
className={cx(
|
||||
className,
|
||||
"flex flex-col",
|
||||
isBetter
|
||||
? css`
|
||||
min-width: 350px;
|
||||
height: 450px;
|
||||
`
|
||||
: width
|
||||
? css`
|
||||
min-width: ${width}px;
|
||||
`
|
||||
: css`
|
||||
min-width: 150px;
|
||||
`,
|
||||
css`
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{isBetter ? (
|
||||
<>
|
||||
<div className="flex flex-row w-full p-1">
|
||||
<div className="flex-grow flex flex-row relative">
|
||||
<div
|
||||
className={cx(
|
||||
"absolute left-0 px-1.5",
|
||||
css`
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`
|
||||
)}
|
||||
>
|
||||
<IconSearch />
|
||||
</div>
|
||||
|
||||
<input
|
||||
placeholder={"Search"}
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
local.search = value;
|
||||
local.render();
|
||||
handleSearch();
|
||||
}}
|
||||
className={cx(
|
||||
"pl-6 pr-3 py-1 rounded-md text-black flex h-9 flex-grow border border-gray-200 bg-white text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground md:text-sm focus:outline-none focus:ring-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{init.search.input === "" || !init.search.input ? (
|
||||
<div className="flex flex-row px-3 py-1 gap-x-2 items-center cursor-pointer">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
className="border border-primary"
|
||||
checked={init?.selectBetter?.all ? true : false}
|
||||
onClick={(e) => {
|
||||
init.selectBetter.all = !init.selectBetter.all;
|
||||
init.render();
|
||||
if (typeof onSelectAll === "function")
|
||||
onSelectAll(init.selectBetter.all);
|
||||
}}
|
||||
/>
|
||||
Select All
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{loading || searching ? (
|
||||
<div
|
||||
className={cx(
|
||||
isBetter && "flex-grow flex flex-row items-center justify-center",
|
||||
"px-4 w-full text-slate-400 text-sm py-2"
|
||||
)}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cx(
|
||||
isBetter &&
|
||||
"flex-grow flex flex-row items-center justify-center",
|
||||
"p-4 w-full text-center text-md text-slate-400"
|
||||
)}
|
||||
>
|
||||
{fitur === "search-add" ? (
|
||||
<ButtonBetter
|
||||
variant={"outline"}
|
||||
className="flex flex-row gap-x-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth={1.5}
|
||||
d="M6 12h12m-6 6V6"
|
||||
></path>
|
||||
</svg>{" "}
|
||||
<span>
|
||||
Add{" "}
|
||||
<span
|
||||
className={css`
|
||||
font-style: italic;
|
||||
`}
|
||||
>
|
||||
"{searchText}"
|
||||
</span>
|
||||
</span>
|
||||
</ButtonBetter>
|
||||
) : (
|
||||
<>
|
||||
{!searchText ? (
|
||||
<>— Empty —</>
|
||||
) : (
|
||||
<>
|
||||
Search
|
||||
<span
|
||||
className={css`
|
||||
font-style: italic;
|
||||
padding: 0px 5px;
|
||||
`}
|
||||
>
|
||||
"{searchText}"
|
||||
</span>
|
||||
not found
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ListUIClean
|
||||
onInit={(e: any) => {
|
||||
local.list = e;
|
||||
local.render();
|
||||
}}
|
||||
classContainer={"border-none p-0"}
|
||||
name="themes"
|
||||
content={({ item }: any) => {
|
||||
const label =
|
||||
typeof onLabel === "function"
|
||||
? onLabel(item)
|
||||
: typeof onLabel === "string"
|
||||
? item[onLabel]
|
||||
: "";
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row gap-x-2 items-center cursor-pointer">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
className="border border-primary"
|
||||
checked={true}
|
||||
onClick={() => {
|
||||
// if (is_selected) {
|
||||
// if (typeof onRemove === "function") onRemove(item);
|
||||
// } else {
|
||||
// onSelect?.(item.value);
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
onLoad={async (param: any) => {
|
||||
const result: any =
|
||||
typeof onLoad === "function"
|
||||
? await onLoad({ ...param, search: local.search })
|
||||
: onLoad;
|
||||
console.log({ result });
|
||||
return result;
|
||||
}}
|
||||
onCount={async (params: any) => {
|
||||
const result: any =
|
||||
typeof onCount === "function"
|
||||
? await onCount({
|
||||
take: 1,
|
||||
paging: 1,
|
||||
search: local.search,
|
||||
})
|
||||
: onCount;
|
||||
return result;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{false ? (
|
||||
<div className="w-full flex flex-row items-center justify-end p-1 border-t border-gray-200">
|
||||
<ButtonBetter className="rounded-md text-xs flex flex-row items-center gap-x-1">
|
||||
<IconCheck />
|
||||
OK
|
||||
</ButtonBetter>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showEmpty && options.length === 0) content = <></>;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
arrow={false}
|
||||
onOpenChange={onOpenChange}
|
||||
backdrop={false}
|
||||
classNameTrigger={!isMulti ? "w-full" : "flex-grow"}
|
||||
placement="bottom-start"
|
||||
className="flex-1 rounded-md overflow-hidden"
|
||||
content={content}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
import { FC } from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { Popover } from "../../Popover/Popover";
|
||||
import { ButtonBetter } from "../../ui/button";
|
||||
import { Checkbox } from "../../ui/checkbox";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
export type OptionItem = { value: string; label: string };
|
||||
export const TypeaheadOptions: FC<{
|
||||
popup?: boolean;
|
||||
onRemove?: (data: any) => void;
|
||||
onSelectAll?: (data: boolean) => void;
|
||||
init?: any;
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
children: any;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
options: OptionItem[];
|
||||
className?: string;
|
||||
showEmpty: boolean;
|
||||
selected?: (arg: {
|
||||
item: OptionItem;
|
||||
options: OptionItem[];
|
||||
idx: number;
|
||||
}) => boolean;
|
||||
onSelect?: (value: string) => void;
|
||||
searching?: boolean;
|
||||
searchText?: string;
|
||||
width?: number;
|
||||
isMulti?: boolean;
|
||||
fitur?: "search-add";
|
||||
isBetter?: boolean;
|
||||
onSearch?: (event: any) => void;
|
||||
search?: boolean;
|
||||
}> = ({
|
||||
popup,
|
||||
loading,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
searching,
|
||||
searchText,
|
||||
showEmpty,
|
||||
width,
|
||||
isMulti,
|
||||
fitur,
|
||||
isBetter,
|
||||
init,
|
||||
onSearch,
|
||||
search,
|
||||
onRemove,
|
||||
onSelectAll,
|
||||
}) => {
|
||||
if (!popup) return children;
|
||||
const local = useLocal({
|
||||
selectedIdx: 0,
|
||||
});
|
||||
|
||||
let content = (
|
||||
<div
|
||||
className={cx(
|
||||
className,
|
||||
"flex flex-col",
|
||||
isBetter
|
||||
? css`
|
||||
min-width: 350px;
|
||||
height: 450px;
|
||||
`
|
||||
: width
|
||||
? css`
|
||||
min-width: ${width}px;
|
||||
`
|
||||
: css`
|
||||
min-width: 150px;
|
||||
`,
|
||||
css`
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{isBetter ? (
|
||||
<>
|
||||
<div className="flex flex-row w-full p-1">
|
||||
<div className="flex-grow flex flex-row relative">
|
||||
<div
|
||||
className={cx(
|
||||
"absolute left-0 px-1.5",
|
||||
css`
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`
|
||||
)}
|
||||
>
|
||||
<IconSearch />
|
||||
</div>
|
||||
|
||||
<input
|
||||
placeholder={"Search"}
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
onChange={onSearch}
|
||||
className={cx(
|
||||
"pl-6 pr-3 py-1 rounded-md text-black flex h-9 flex-grow border border-gray-200 bg-white text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground md:text-sm focus:outline-none focus:ring-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{init.search.input === "" || !init.search.input ? (
|
||||
<div className="flex flex-row px-3 py-1 gap-x-2 items-center cursor-pointer">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
className="border border-primary"
|
||||
checked={init?.selectBetter?.all ? true : false}
|
||||
onClick={(e) => {
|
||||
init.selectBetter.all = !init.selectBetter.all;
|
||||
init.render();
|
||||
if (typeof onSelectAll === "function")
|
||||
onSelectAll(init.selectBetter.all);
|
||||
}}
|
||||
/>
|
||||
Select All
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{loading || searching ? (
|
||||
<div
|
||||
className={cx(
|
||||
isBetter && "flex-grow flex flex-row items-center justify-center",
|
||||
"px-4 w-full text-slate-400 text-sm py-2"
|
||||
)}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cx(
|
||||
isBetter &&
|
||||
"flex-grow flex flex-row items-center justify-center",
|
||||
"p-4 w-full text-center text-md text-slate-400"
|
||||
)}
|
||||
>
|
||||
{fitur === "search-add" ? (
|
||||
<ButtonBetter
|
||||
variant={"outline"}
|
||||
className="flex flex-row gap-x-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth={1.5}
|
||||
d="M6 12h12m-6 6V6"
|
||||
></path>
|
||||
</svg>{" "}
|
||||
<span>
|
||||
Add{" "}
|
||||
<span
|
||||
className={css`
|
||||
font-style: italic;
|
||||
`}
|
||||
>
|
||||
"{searchText}"
|
||||
</span>
|
||||
</span>
|
||||
</ButtonBetter>
|
||||
) : (
|
||||
<>
|
||||
{!searchText ? (
|
||||
<>— Empty —</>
|
||||
) : (
|
||||
<>
|
||||
Search
|
||||
<span
|
||||
className={css`
|
||||
font-style: italic;
|
||||
padding: 0px 5px;
|
||||
`}
|
||||
>
|
||||
"{searchText}"
|
||||
</span>
|
||||
not found
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : isBetter ? (
|
||||
<ScrollArea className="w-full flex-grow flex flex-col gap-y-2">
|
||||
{options.map((item, idx) => {
|
||||
const is_selected = isBetter
|
||||
? init.selectBetter.all
|
||||
? true
|
||||
: selected?.({ item, options, idx })
|
||||
: selected?.({ item, options, idx });
|
||||
|
||||
if (is_selected) {
|
||||
local.selectedIdx = idx;
|
||||
}
|
||||
if (isBetter) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row px-3 py-1 gap-x-2 items-center cursor-pointer"
|
||||
key={item.value + "_" + idx}
|
||||
>
|
||||
<Checkbox
|
||||
id="terms"
|
||||
className="border border-primary"
|
||||
checked={is_selected}
|
||||
onClick={() => {
|
||||
if (is_selected) {
|
||||
if (typeof onRemove === "function") onRemove(item);
|
||||
} else {
|
||||
onSelect?.(item.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{item.label || <> </>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
key={item.value + "_" + idx}
|
||||
className={cx(
|
||||
"opt-item px-3 py-1 cursor-pointer option-item text-sm",
|
||||
is_selected
|
||||
? "bg-blue-600 text-white"
|
||||
: "hover:bg-blue-50",
|
||||
idx > 0 && "border-t"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect?.(item.value);
|
||||
}}
|
||||
>
|
||||
{item.label || <> </>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="w-full flex-grow flex flex-col">
|
||||
{options.map((item, idx) => {
|
||||
const is_selected = isBetter
|
||||
? init.selectBetter.all
|
||||
? true
|
||||
: selected?.({ item, options, idx })
|
||||
: selected?.({ item, options, idx });
|
||||
|
||||
if (is_selected) {
|
||||
local.selectedIdx = idx;
|
||||
}
|
||||
if (isBetter) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row px-3 py-1 gap-x-2 items-center cursor-pointer"
|
||||
key={item.value + "_" + idx}
|
||||
>
|
||||
<Checkbox
|
||||
id="terms"
|
||||
className="border border-primary"
|
||||
checked={is_selected}
|
||||
onClick={() => {
|
||||
if (is_selected) {
|
||||
if (typeof onRemove === "function") onRemove(item);
|
||||
} else {
|
||||
onSelect?.(item.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{item.label || <> </>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
key={item.value + "_" + idx}
|
||||
className={cx(
|
||||
"opt-item px-3 py-1 cursor-pointer option-item text-sm",
|
||||
is_selected
|
||||
? "bg-blue-600 text-white"
|
||||
: "hover:bg-blue-50",
|
||||
idx > 0 && "border-t"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect?.(item.value);
|
||||
}}
|
||||
>
|
||||
{item.label || <> </>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{false ? (
|
||||
<div className="w-full flex flex-row items-center justify-end p-1 border-t border-gray-200">
|
||||
<ButtonBetter className="rounded-md text-xs flex flex-row items-center gap-x-1">
|
||||
<IconCheck />
|
||||
OK
|
||||
</ButtonBetter>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showEmpty && options.length === 0) content = <></>;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
arrow={false}
|
||||
onOpenChange={onOpenChange}
|
||||
backdrop={false}
|
||||
classNameTrigger={!isMulti ? "w-full" : "flex-grow"}
|
||||
placement="bottom-start"
|
||||
className="flex-1 rounded-md overflow-hidden"
|
||||
content={content}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import get from "lodash.get";
|
||||
import { ListBetter } from "../tablelist/List";
|
||||
import { cn } from "@/lib/utils";
|
||||
export const ListUI: React.FC<any> = ({
|
||||
tabHeader,
|
||||
className,
|
||||
classNameScrollArea,
|
||||
name,
|
||||
modeTab,
|
||||
column,
|
||||
align = "center",
|
||||
onLoad,
|
||||
take = 20,
|
||||
header,
|
||||
disabledPagination,
|
||||
disabledHeader,
|
||||
disabledHeadTable,
|
||||
hiddenNoRow,
|
||||
disabledHoverRow,
|
||||
onInit,
|
||||
onCount,
|
||||
fm,
|
||||
mode,
|
||||
feature,
|
||||
onChange,
|
||||
delete_name,
|
||||
title,
|
||||
tab,
|
||||
onTab,
|
||||
breadcrumb,
|
||||
classNameContainer,
|
||||
content,
|
||||
ready = true,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
tab: get(tab, "[0].id"),
|
||||
table: {
|
||||
count: 0 as number,
|
||||
} as any,
|
||||
show: true as boolean,
|
||||
count: 0 as number,
|
||||
readyTitle: true,
|
||||
});
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex-grow flex-grow flex flex-row items-center justify-center">
|
||||
<div className="spinner-better"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-grow rounded-lg border border-gray-200 py-2 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex flex-col flex-grow">
|
||||
{title ? (
|
||||
<div className="flex flex-col w-full px-4 pt-2">
|
||||
{typeof title === "function"
|
||||
? title({ ui: local, count: local.count })
|
||||
: title}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="w-full flex flex-row flex-grow overflow-hidden ">
|
||||
<ListBetter
|
||||
name={name}
|
||||
classNameScrollArea={classNameScrollArea}
|
||||
classNameContainer={classNameContainer}
|
||||
content={content}
|
||||
onLoad={onLoad}
|
||||
onCount={async (params: any) => {
|
||||
const result = await onCount();
|
||||
local.count = result;
|
||||
local.render();
|
||||
return result;
|
||||
}}
|
||||
onInit={(e: any) => {
|
||||
local.readyTitle = false;
|
||||
local.table = e;
|
||||
local.render();
|
||||
setTimeout(() => {
|
||||
local.readyTitle = true;
|
||||
local.render();
|
||||
}, 100);
|
||||
if (typeof onInit === "function") {
|
||||
onInit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import get from "lodash.get";
|
||||
import { ListBetter } from "../tablelist/List";
|
||||
import { cn } from "@/lib/utils";
|
||||
export const ListUIClean: React.FC<any> = ({
|
||||
tabHeader,
|
||||
name,
|
||||
modeTab,
|
||||
column,
|
||||
align = "center",
|
||||
onLoad,
|
||||
take = 20,
|
||||
header,
|
||||
disabledPagination,
|
||||
disabledHeader,
|
||||
disabledHeadTable,
|
||||
hiddenNoRow,
|
||||
disabledHoverRow,
|
||||
onInit,
|
||||
onCount,
|
||||
fm,
|
||||
mode,
|
||||
feature,
|
||||
onChange,
|
||||
delete_name,
|
||||
title,
|
||||
tab,
|
||||
onTab,
|
||||
breadcrumb,
|
||||
content,
|
||||
ready = true,
|
||||
classContainer,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
tab: get(tab, "[0].id"),
|
||||
table: {
|
||||
count: 0 as number,
|
||||
} as any,
|
||||
show: true as boolean,
|
||||
count: 0 as number,
|
||||
readyTitle: true,
|
||||
});
|
||||
const labelTitle =
|
||||
typeof title === "function"
|
||||
? title({ ui: local, count: local.count })
|
||||
: title;
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex-grow flex-grow flex flex-row items-center justify-center">
|
||||
<div className="spinner-better"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-grow rounded-lg border border-gray-200 py-2 overflow-hidden h-full",
|
||||
classContainer
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex flex-col flex-grow">
|
||||
{labelTitle ? (
|
||||
<>
|
||||
<div className="flex flex-col w-full px-4 pt-2">
|
||||
{typeof title === "function"
|
||||
? title({ ui: local, count: local.table.count })
|
||||
: title}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="w-full h-full flex flex-row flex-grow overflow-hidden ">
|
||||
<ListBetter
|
||||
name={name}
|
||||
content={content}
|
||||
onLoad={onLoad}
|
||||
onCount={async (params: any) => {
|
||||
const result = await onCount(params);
|
||||
local.count = result;
|
||||
local.render();
|
||||
return result;
|
||||
}}
|
||||
onInit={(e: any) => {
|
||||
local.readyTitle = false;
|
||||
local.table = e;
|
||||
local.render();
|
||||
setTimeout(() => {
|
||||
local.readyTitle = true;
|
||||
local.render();
|
||||
}, 100);
|
||||
if (typeof onInit === "function") {
|
||||
onInit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||
import { login } from "@/store/authSlice";
|
||||
import type { RootState } from "@/store";
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const {
|
||||
token,
|
||||
loading: authLoading,
|
||||
error: authError,
|
||||
} = useAppSelector((s: RootState) => s.auth);
|
||||
|
||||
// redirect when token appears
|
||||
React.useEffect(() => {
|
||||
if (token) router.push("/d/dashboard");
|
||||
}, [token, router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const username = (formData.get("username") as string) || "";
|
||||
const password = (formData.get("password") as string) || "";
|
||||
|
||||
try {
|
||||
// dispatch login thunk
|
||||
// note: unwrap() could be used here to throw if rejected, but we'll rely on auth state
|
||||
dispatch(login({ username, password }));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Login failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>Login with your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="your-username"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={authLoading}>
|
||||
{authLoading ? "Logging in..." : "Login"}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account? <a href="#">Sign up</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
{(error || authError) && (
|
||||
<div className="mt-2 text-sm text-destructive">
|
||||
{error || authError}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
IconDots,
|
||||
IconFolder,
|
||||
IconShare3,
|
||||
IconTrash,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.url || pathname?.startsWith(item.url + "/");
|
||||
return (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a
|
||||
className={
|
||||
isActive
|
||||
? "!bg-muted text-primary-foreground flex items-center gap-2"
|
||||
: "flex items-center gap-2"
|
||||
}
|
||||
href={item.url}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="data-[state=open]:bg-accent rounded-sm"
|
||||
>
|
||||
<IconDots />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-24 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<IconFolder />
|
||||
<span>Open</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconShare3 />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<IconTrash />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<IconDots className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
}[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="flex items-center gap-2">
|
||||
<SidebarMenuButton
|
||||
tooltip="Quick Create"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||
>
|
||||
<IconCirclePlusFilled />
|
||||
<span>Quick Create</span>
|
||||
</SidebarMenuButton>
|
||||
<Button
|
||||
size="icon"
|
||||
className="size-8 group-data-[collapsible=icon]:opacity-0"
|
||||
variant="outline"
|
||||
>
|
||||
<IconMail />
|
||||
<span className="sr-only">Inbox</span>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.url || pathname?.startsWith(item.url + "/");
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<Link
|
||||
href={item.url}
|
||||
className={`flex items-center gap-2 ${
|
||||
isActive ? "!bg-muted text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type Icon } from "@tabler/icons-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.url || pathname?.startsWith(item.url + "/");
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a
|
||||
className={
|
||||
isActive
|
||||
? "!bg-muted text-primary-foreground flex items-center gap-2"
|
||||
: "flex items-center gap-2"
|
||||
}
|
||||
href={item.url}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconDotsVertical,
|
||||
IconLogout,
|
||||
IconNotification,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
<IconDotsVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="hidden" />
|
||||
<DropdownMenuGroup className="hidden">
|
||||
<DropdownMenuItem>
|
||||
<IconUserCircle />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconNotification />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
await fetch(
|
||||
process.env.NEXT_PUBLIC_API_URL + "/auth/logout",
|
||||
{ method: "POST", headers }
|
||||
);
|
||||
localStorage.removeItem("authToken");
|
||||
window.location.href = "/login";
|
||||
} catch (e) {
|
||||
alert("Logout failed");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconLogout />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
export function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Trending up this month <IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Down 20% this period <IconTrendingDown className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Acquisition needs attention
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Strong user retention <IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Engagement exceed targets</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconTrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Steady performance increase <IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Meets growth projections</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
import * as React from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { usePageTitle } from "@/providers/PageTitleProvider";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
|
||||
export function SiteHeader() {
|
||||
const { title } = usePageTitle();
|
||||
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">
|
||||
{Array.isArray(title) ? (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="flex items-center gap-2">
|
||||
{title.map((t, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<BreadcrumbItem>
|
||||
{t.url ? (
|
||||
<BreadcrumbLink href={t.url}>{t.label}</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{t.label}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{i < title.length - 1 && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* <Button variant="ghost" asChild size="sm" className="hidden sm:flex">
|
||||
<a
|
||||
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="dark:text-foreground"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import get from "lodash.get";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const ListBetter: React.FC<any> = ({
|
||||
classNameContainer,
|
||||
classNameScrollArea,
|
||||
autoPagination = true,
|
||||
name,
|
||||
column,
|
||||
style = "UI",
|
||||
align = "center",
|
||||
onLoad,
|
||||
take = 10,
|
||||
header,
|
||||
disabledPagination,
|
||||
disabledHeader,
|
||||
disabledHeadTable,
|
||||
hiddenNoRow,
|
||||
disabledHoverRow,
|
||||
onInit,
|
||||
onCount,
|
||||
fm,
|
||||
mode,
|
||||
feature,
|
||||
onChange,
|
||||
content,
|
||||
}) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [maxPage, setMaxPage] = useState<number>(0);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
const local = useLocal({
|
||||
table: null as any,
|
||||
data: [] as any[],
|
||||
dataForm: [] as any[],
|
||||
listData: [] as any[],
|
||||
sort: {} as any,
|
||||
search: null as any,
|
||||
paging: 1,
|
||||
maxPage: 1,
|
||||
count: 0 as any,
|
||||
ready: false,
|
||||
addRow: (row: any) => {
|
||||
setData((prev) => [...prev, row]);
|
||||
local.data.push(row);
|
||||
local.render();
|
||||
},
|
||||
selection: {
|
||||
all: false,
|
||||
partial: [] as any[],
|
||||
data: [] as any[],
|
||||
},
|
||||
renderRow: (row: any) => {
|
||||
setData((prev) => [...prev, row]);
|
||||
local.data = data;
|
||||
local.render();
|
||||
},
|
||||
removeRow: (row: any) => {
|
||||
setData((prev) => prev.filter((item) => item !== row)); // Update state lokal
|
||||
local.data = local.data.filter((item: any) => item !== row); // Hapus row dari local.data
|
||||
local.render(); // Panggil render untuk memperbarui UI
|
||||
},
|
||||
refresh: async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
local.ready = false;
|
||||
local.render();
|
||||
try {
|
||||
if (typeof onCount === "function") {
|
||||
const res = await onCount();
|
||||
local.count = res;
|
||||
local.maxPage = Math.ceil(res / take);
|
||||
local.paging = 1;
|
||||
local.render();
|
||||
}
|
||||
if (Array.isArray(onLoad)) {
|
||||
let res = onLoad;
|
||||
local.data = res;
|
||||
local.render();
|
||||
setData(res);
|
||||
} else {
|
||||
let res: any = await onLoad({
|
||||
search: local.search,
|
||||
sort: local.sort,
|
||||
take,
|
||||
paging: 1,
|
||||
});
|
||||
local.data = res;
|
||||
local.render();
|
||||
setData(res);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(get(ex, "response.data.meta.message") || ex.message);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
local.ready = true;
|
||||
local.render();
|
||||
},
|
||||
reload: async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
const listData = local.data || [];
|
||||
try {
|
||||
if (Array.isArray(onLoad)) {
|
||||
let res = onLoad;
|
||||
local.data = listData.concat(res);
|
||||
local.render();
|
||||
setData(res);
|
||||
} else {
|
||||
let res: any = await onLoad({
|
||||
search: local.search,
|
||||
sort: local.sort,
|
||||
take,
|
||||
paging: local.paging,
|
||||
});
|
||||
local.data = listData.concat(res);
|
||||
local.render();
|
||||
setData(res);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(get(ex, "response.data.meta.message") || ex.message);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
local.ready = false;
|
||||
local.render();
|
||||
try {
|
||||
if (typeof onCount === "function") {
|
||||
const res = await onCount();
|
||||
setMaxPage(Math.ceil(res / take));
|
||||
local.maxPage = Math.ceil(res / take);
|
||||
local.count = res;
|
||||
local.render();
|
||||
}
|
||||
if (mode === "form") {
|
||||
local.data = fm.data?.[name] || [];
|
||||
local.render();
|
||||
setData(fm.data?.[name] || []);
|
||||
} else {
|
||||
if (Array.isArray(onLoad)) {
|
||||
local.data = onLoad;
|
||||
local.render();
|
||||
setData(onLoad);
|
||||
} else if (typeof onLoad === "function") {
|
||||
let res: any = await onLoad({
|
||||
search: local.search,
|
||||
sort: local.sort,
|
||||
take,
|
||||
paging: 1,
|
||||
});
|
||||
local.data = res;
|
||||
local.render();
|
||||
setData(local.data);
|
||||
} else {
|
||||
let res = onLoad;
|
||||
local.data = res;
|
||||
local.render();
|
||||
setData(local.data);
|
||||
}
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(get(ex, "response.data.meta.message") || ex.message);
|
||||
}
|
||||
if (typeof onInit === "function") {
|
||||
onInit(local);
|
||||
}
|
||||
local.ready = true;
|
||||
local.render();
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
const observerRef: any = React.useRef();
|
||||
|
||||
const lastPostRef = React.useCallback((node: any) => {
|
||||
if (observerRef) {
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
if (local.paging < local.maxPage) {
|
||||
local.paging = local.paging + 1;
|
||||
local.render();
|
||||
local.reload();
|
||||
setReload((r) => r + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (node) observerRef.current.observe(node);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<div className="tbl-wrapper flex flex-grow flex-col">
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"w-full h-full flex flex-col gap-y-4 p-4",
|
||||
classNameScrollArea
|
||||
)}
|
||||
reload={reload}
|
||||
>
|
||||
{!local.ready ? (
|
||||
<>
|
||||
<div className="flex-grow h-full flex-grow flex flex-row items-center justify-center">
|
||||
<div className="spinner-better"></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-grow flex flex-col gap-y-4",
|
||||
classNameContainer
|
||||
)}
|
||||
>
|
||||
{Array.isArray(local.data) && local.data?.length ? (
|
||||
local.data?.map((e, idx) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full"
|
||||
key={`items-${name}-${idx}`}
|
||||
ref={local.data?.length === idx + 1 ? lastPostRef : null}
|
||||
>
|
||||
{typeof content === "function"
|
||||
? content({ item: e, idx, tbl: local })
|
||||
: content}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,663 @@
|
|||
"use client";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { init_column } from "./lib/column";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Sticker } from "lucide-react";
|
||||
import { getNumber } from "@/lib/utils/getNumber";
|
||||
import { formatMoney } from "@/components/form/field/TypeInput";
|
||||
import "react-resizable/css/styles.css";
|
||||
import { Resizable } from "react-resizable";
|
||||
import get from "lodash.get";
|
||||
import { Table } from "../ui/table";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
export const TableEditBetter: React.FC<any> = ({
|
||||
name,
|
||||
column,
|
||||
align = "center",
|
||||
onLoad,
|
||||
take = 20,
|
||||
header,
|
||||
disabledPagination,
|
||||
disabledHeader,
|
||||
disabledHeadTable,
|
||||
hiddenNoRow,
|
||||
disabledHoverRow,
|
||||
onInit,
|
||||
onCount,
|
||||
fm,
|
||||
mode,
|
||||
feature,
|
||||
onChange,
|
||||
delete_name,
|
||||
}) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columns, setColumns] = useState([] as any[]);
|
||||
const sideLeft =
|
||||
typeof header?.sideLeft === "function" ? header.sideLeft : null;
|
||||
const sideRight =
|
||||
typeof header?.sideRight === "function" ? header.sideRight : null;
|
||||
const checkbox =
|
||||
Array.isArray(feature) && feature?.length
|
||||
? feature.includes("checkbox")
|
||||
: false;
|
||||
const local = useLocal({
|
||||
table: null as any,
|
||||
data: [] as any[],
|
||||
dataForm: [] as any[],
|
||||
listData: [] as any[],
|
||||
sort: {} as any,
|
||||
search: null as any,
|
||||
count: 0 as any,
|
||||
addRow: (row: any) => {
|
||||
const data = fm.data?.[name] || [];
|
||||
data.push(row);
|
||||
fm.data[name] = data;
|
||||
fm.render();
|
||||
local.data = fm.data[name];
|
||||
local.render();
|
||||
},
|
||||
selection: {
|
||||
all: false,
|
||||
partial: [] as any[],
|
||||
},
|
||||
renderRow: (row: any) => {
|
||||
setData((prev) => [...prev, row]);
|
||||
local.data = data;
|
||||
local.render();
|
||||
},
|
||||
removeRow: (row: any) => {
|
||||
const data = fm.data?.[name] || [];
|
||||
if (delete_name) {
|
||||
const ids: any[] = Array.isArray(fm.data?.[delete_name])
|
||||
? fm.data?.deleted_line_ids
|
||||
: [];
|
||||
if (row?.id) {
|
||||
ids.push(row.id);
|
||||
}
|
||||
fm.data[delete_name] = ids;
|
||||
}
|
||||
fm.data[name] = data.filter((item: any) => item !== row);
|
||||
fm.render();
|
||||
local.data = fm.data[name];
|
||||
local.render();
|
||||
},
|
||||
reload: async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
try {
|
||||
if (Array.isArray(onLoad)) {
|
||||
local.data = onLoad;
|
||||
local.render();
|
||||
setData(onLoad);
|
||||
} else {
|
||||
const res: any = onLoad({
|
||||
search: local.search,
|
||||
sort: local.sort,
|
||||
take,
|
||||
paging: 1,
|
||||
});
|
||||
if (res instanceof Promise) {
|
||||
res.then((e) => {
|
||||
local.data = e;
|
||||
local.render();
|
||||
setData(e);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
});
|
||||
} else {
|
||||
local.data = res;
|
||||
local.render();
|
||||
setData(res);
|
||||
}
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(get(ex, "response.data.meta.message") || ex.message);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
// const cloneListFM = (data: any[]) => {
|
||||
// if (mode === "form") {
|
||||
// local.dataForm = data.map((e: any) => cloneFM(fm, e));
|
||||
// local.render();
|
||||
// }
|
||||
// };
|
||||
useEffect(() => {
|
||||
const defaultColumns: any[] = init_column(column);
|
||||
const col = defaultColumns?.length
|
||||
? defaultColumns.map((e: any) => {
|
||||
return {
|
||||
...e,
|
||||
width: e?.width || 150,
|
||||
};
|
||||
})
|
||||
: ([] as any[]);
|
||||
setColumns(col);
|
||||
local.data = fm?.data[name] || [];
|
||||
local.render();
|
||||
fm.fields[name] = {
|
||||
name: name,
|
||||
type: "table",
|
||||
fields: [],
|
||||
};
|
||||
fm.render();
|
||||
}, []);
|
||||
|
||||
const handleResize = (index: any, width: any) => {
|
||||
setColumns((prevColumns: any) => {
|
||||
const updatedColumns = [...prevColumns];
|
||||
updatedColumns[index].width = width;
|
||||
return updatedColumns;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="tbl-wrapper flex flex-grow flex-col">
|
||||
{!disabledHeader ? (
|
||||
<div className="px-2 head-tbl-list block items-start justify-between bg-white px-0 py-4 sm:flex">
|
||||
<div className="flex flex-row h-full">
|
||||
<div className="sm:flex flex flex-col space-y-2 flex-grow">
|
||||
<div className="flex flex-grow flex-row">
|
||||
{sideLeft ? sideLeft(local) : <></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center flex-row">
|
||||
<div className="flex">{sideRight ? sideRight(local) : <></>}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="overflow-auto relative flex-grow flex-row">
|
||||
<div className="tbl absolute top-0 left-0 inline-block flex-grow w-full h-full align-middle">
|
||||
<div className="relative">
|
||||
<Table
|
||||
className={cx(
|
||||
"min-w-full divide-y divide-gray-200 text-black",
|
||||
css`
|
||||
thead th:first-child {
|
||||
overflow: hidden;
|
||||
border-top-left-radius: 10px; /* Sudut kiri atas */
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
thead th:last-child {
|
||||
overflow: hidden;
|
||||
border-top-right-radius: 10px; /* Sudut kiri atas */
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
tbody td:first-child {
|
||||
overflow: hidden;
|
||||
border-top-left-radius: 10px; /* Sudut kiri atas */
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
tbody td:last-child {
|
||||
overflow: hidden;
|
||||
border-top-right-radius: 10px; /* Sudut kiri atas */
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
.react-resizable-handle {
|
||||
cursor: e-resize;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #313678;
|
||||
}
|
||||
.react-resizable {
|
||||
}
|
||||
`,
|
||||
checkbox &&
|
||||
css`
|
||||
.table-header-tbl > th:first-child {
|
||||
width: 20px !important; /* Atur lebar sesuai kebutuhan */
|
||||
text-align: center;
|
||||
}
|
||||
.table-row-element > td:first-child {
|
||||
width: 20px !important; /* Atur lebar sesuai kebutuhan */
|
||||
text-align: center;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
{!disabledHeadTable ? (
|
||||
<thead
|
||||
className={cx(
|
||||
"rounded-md overflow-hidden text-md bg-primary group/head text-md uppercase text-white sticky top-0",
|
||||
css`
|
||||
z-index: 1;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<tr className={"table-header-tbl"}>
|
||||
{columns.map((col, idx) => {
|
||||
return (
|
||||
<HeaderColumn
|
||||
col={col}
|
||||
key={`${col?.accessorKey}_${idx}`}
|
||||
width={col.width}
|
||||
height={0}
|
||||
onResize={(e: any, { size }: any) =>
|
||||
handleResize(idx, size.width)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center h-full flex-grow p-2">
|
||||
<span>
|
||||
{typeof col?.header === "function"
|
||||
? col?.header()
|
||||
: col.header}
|
||||
</span>
|
||||
</div>
|
||||
</HeaderColumn>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<tbody>
|
||||
{local.data.map((row: any, index: any) => {
|
||||
if (
|
||||
typeof fm.fields?.[name]?.fields?.[index]?.fields !==
|
||||
"object"
|
||||
) {
|
||||
fm.fields[name].fields[index] = {
|
||||
fields: {},
|
||||
};
|
||||
}
|
||||
const fm_row = {
|
||||
...fm,
|
||||
name: name,
|
||||
type: "table",
|
||||
data: row,
|
||||
error: fm.fields?.[name]?.fields?.[index]?.error,
|
||||
fields: fm.fields?.[name]?.fields?.[index]?.fields,
|
||||
render: () => {
|
||||
local.render();
|
||||
fm.data[name] = local.data;
|
||||
fm.render();
|
||||
},
|
||||
};
|
||||
return (
|
||||
<tr
|
||||
key={`row_${name}_${index}`}
|
||||
className={cx(css`
|
||||
td {
|
||||
vertical-align: ${align};
|
||||
}
|
||||
`)}
|
||||
>
|
||||
{columns.map((col, idx) => {
|
||||
const param = {
|
||||
row: row,
|
||||
name: col?.name,
|
||||
idx,
|
||||
tbl: local,
|
||||
fm_row: fm_row,
|
||||
onChange,
|
||||
};
|
||||
const renderData =
|
||||
typeof col?.renderCell === "function" ? (
|
||||
col.renderCell(param)
|
||||
) : (
|
||||
<>No Column</>
|
||||
);
|
||||
return (
|
||||
<td
|
||||
key={`row_${name}_${index}_${col?.accessorKey}_${idx}`}
|
||||
className={
|
||||
"table-header-tbl align-top capitalize"
|
||||
}
|
||||
>
|
||||
<div className="p-1">{renderData}</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{!local?.data?.length && (
|
||||
<div
|
||||
className={cx(
|
||||
"flex-1 w-full absolute inset-0 flex flex-col items-center justify-center",
|
||||
css`
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[15%] flex flex-col items-center">
|
||||
<Sticker size={35} strokeWidth={1} />
|
||||
<div className="pt-1 text-center">No Data</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const HeaderColumn: FC<any> = ({ children, width, height, onResize, col }) => {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const handleResizeStart = () => {
|
||||
setIsResizing(true);
|
||||
};
|
||||
|
||||
const handleResizeStop = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
onResizeStart={handleResizeStart}
|
||||
onResizeStop={handleResizeStop}
|
||||
width={width}
|
||||
height={height}
|
||||
onResize={onResize}
|
||||
>
|
||||
<th
|
||||
className="table-header-tbl capitalize relative"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className={cx(
|
||||
css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 5px;
|
||||
background: transparent;
|
||||
cursor: e-resize;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #4a90e2; /* Warna biru saat hover */
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #357abd; /* Warna biru lebih gelap saat di-resize */
|
||||
}
|
||||
|
||||
${isResizing &&
|
||||
css`
|
||||
background: #357abd; /* Warna biru lebih gelap saat resize aktif */
|
||||
opacity: 0.8;
|
||||
`}
|
||||
`
|
||||
)}
|
||||
></div>
|
||||
</th>
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
export const Pagination: React.FC<any> = ({
|
||||
onNextPage,
|
||||
onPrevPage,
|
||||
disabledNextPage,
|
||||
disabledPrevPage,
|
||||
page,
|
||||
count,
|
||||
list,
|
||||
setPage,
|
||||
onChangePage,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
page: 1 as any,
|
||||
pagination: [] as any,
|
||||
});
|
||||
useEffect(() => {
|
||||
local.page = page;
|
||||
local.pagination = getPagination(page, Math.ceil(count / 20));
|
||||
local.render();
|
||||
}, [page, count]);
|
||||
return (
|
||||
<div className=" border-t border-gray-300 tbl-pagination sticky text-sm bottom-0 right-0 w-full grid grid-cols-3 gap-4 justify-end text-sm bg-white pt-2">
|
||||
<div className="flex flex-row items-center text-gray-600">
|
||||
Showing {local.page * 20 - 19} to{" "}
|
||||
{list.data?.length >= 20
|
||||
? local.page * 20
|
||||
: local.page === 1 && Math.ceil(count / 20) === 1
|
||||
? list.data?.length
|
||||
: local.page * 20 - 19 + list.data?.length}{" "}
|
||||
of {formatMoney(getNumber(count))} results
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<div>
|
||||
<nav
|
||||
className="isolate inline-flex -space-x-px flex flex-row items-center gap-x-2"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
{local.pagination.map((e: any, idx: number) => {
|
||||
return (
|
||||
<div
|
||||
key={"page_" + idx}
|
||||
onClick={() => {
|
||||
if (e?.label !== "...") {
|
||||
local.page = getNumber(e?.label);
|
||||
local.render();
|
||||
onChangePage(local.page - 1);
|
||||
setPage(local.page - 1);
|
||||
list.reload();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"text-sm px-2 py-1",
|
||||
e.active
|
||||
? "relative z-10 inline-flex items-center bg-primary font-semibold text-white rounded-md"
|
||||
: e?.label === "..."
|
||||
? "relative z-10 inline-flex items-center font-semibold text-gray-800 rounded-md"
|
||||
: "cursor-pointer relative z-10 inline-flex items-center hover:bg-gray-100 font-semibold text-gray-800 rounded-md"
|
||||
)}
|
||||
>
|
||||
{e?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-end">
|
||||
<div className="flex items-center flex-row gap-x-2 sm:mb-0 text-sm">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledPrevPage) {
|
||||
onPrevPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded p-1 ",
|
||||
disabledPrevPage
|
||||
? "text-gray-200 border-gray-200 border px-2"
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border px-2"
|
||||
)}
|
||||
>
|
||||
<IconChevronLeft className="text-sm" />
|
||||
<span>Previous</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledNextPage) {
|
||||
onNextPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded p-1 ",
|
||||
disabledNextPage
|
||||
? "text-gray-200 border-gray-200 border px-2"
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border px-2"
|
||||
)}
|
||||
>
|
||||
<span>Next</span>
|
||||
<IconChevronRight className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const PaginationPage: React.FC<any> = ({
|
||||
onNextPage,
|
||||
onPrevPage,
|
||||
disabledNextPage,
|
||||
disabledPrevPage,
|
||||
page,
|
||||
count,
|
||||
list,
|
||||
take,
|
||||
setPage,
|
||||
onChangePage,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
page: 1 as any,
|
||||
pagination: [] as any,
|
||||
});
|
||||
useEffect(() => {
|
||||
local.page = page;
|
||||
local.pagination = getPagination(page, Math.ceil(count / take));
|
||||
local.render();
|
||||
}, [page, count]);
|
||||
return (
|
||||
<div className=" tbl-pagination text-sm bottom-0 right-0 w-full grid grid-cols-1 gap-4 justify-center text-sm bg-white pt-2">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<div className="flex items-center flex-row gap-x-2 sm:mb-0 text-sm">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledPrevPage) {
|
||||
onPrevPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded-full p-2 text-md",
|
||||
disabledPrevPage
|
||||
? "text-gray-200 border-gray-200 border "
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border "
|
||||
)}
|
||||
>
|
||||
<IconChevronLeft />
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<div>
|
||||
<nav
|
||||
className="isolate inline-flex -space-x-px flex flex-row items-center gap-x-2"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
{local.pagination.map((e: any, idx: number) => {
|
||||
return (
|
||||
<div
|
||||
key={"page_" + idx}
|
||||
onClick={() => {
|
||||
if (e?.label !== "...") {
|
||||
local.page = getNumber(e?.label);
|
||||
local.render();
|
||||
onChangePage(local.page - 1);
|
||||
setPage(local.page - 1);
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"text-md px-2.5 py-1",
|
||||
e.active
|
||||
? "relative z-10 inline-flex items-center bg-primary font-semibold text-white rounded-full"
|
||||
: e?.label === "..."
|
||||
? "relative z-10 inline-flex items-center font-semibold text-gray-800 rounded-full"
|
||||
: "cursor-pointer relative z-10 inline-flex items-center hover:bg-gray-100 font-semibold text-gray-800 rounded-full"
|
||||
)}
|
||||
>
|
||||
{e?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledNextPage) {
|
||||
onNextPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded-full p-2 ",
|
||||
disabledNextPage
|
||||
? "text-gray-200 border-gray-200 border"
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border "
|
||||
)}
|
||||
>
|
||||
<IconChevronRight className="text-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getPagination = (currentPage: number, totalPages: number) => {
|
||||
const pagination: { label: string; active: boolean }[] = [];
|
||||
const maxVisible = 5; // Jumlah maksimal elemen yang ditampilkan
|
||||
const halfRange = Math.floor((maxVisible - 3) / 2);
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
// Jika total halaman lebih kecil dari batas, tampilkan semua halaman
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pagination.push({ label: i.toString(), active: i === currentPage });
|
||||
}
|
||||
} else {
|
||||
pagination.push({ label: "1", active: currentPage === 1 }); // Halaman pertama selalu ada
|
||||
|
||||
if (currentPage > halfRange + 2) {
|
||||
pagination.push({ label: "...", active: false }); // Awal titik-titik
|
||||
}
|
||||
|
||||
const startPage = Math.max(2, currentPage - halfRange);
|
||||
const endPage = Math.min(totalPages - 1, currentPage + halfRange);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pagination.push({ label: i.toString(), active: i === currentPage });
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - halfRange - 1) {
|
||||
pagination.push({ label: "...", active: false }); // Akhir titik-titik
|
||||
}
|
||||
|
||||
pagination.push({
|
||||
label: totalPages.toString(),
|
||||
active: currentPage === totalPages,
|
||||
}); // Halaman terakhir selalu ada
|
||||
}
|
||||
|
||||
return pagination;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,995 @@
|
|||
"use client";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnResizeDirection,
|
||||
ColumnResizeMode,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
IconCaretUpDownFilled,
|
||||
IconCaretUpFilled,
|
||||
IconCaretDownFilled as IconCaretDown,
|
||||
IconPlus,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { debouncedHandler } from "@/lib/utils/debounceHandler";
|
||||
import Link from "next/link";
|
||||
import { init_column } from "./lib/column";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Sticker } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Table, TableBody, TableCell, TableRow } from "../ui/table";
|
||||
import { Label } from "../ui/label";
|
||||
import { InputSearch } from "../ui/input-search";
|
||||
import get from "lodash.get";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { getNumber } from "@/lib/utils/getNumber";
|
||||
import { formatMoney } from "@/components/form/field/TypeInput";
|
||||
|
||||
export const TableListBetter: React.FC<any> = ({
|
||||
name,
|
||||
column,
|
||||
align = "center",
|
||||
onLoad,
|
||||
take = 20,
|
||||
header,
|
||||
disabledPagination,
|
||||
disabledHeader,
|
||||
disabledHeadTable,
|
||||
hiddenNoRow,
|
||||
disabledHoverRow,
|
||||
onInit,
|
||||
onCount,
|
||||
fm,
|
||||
mode,
|
||||
feature,
|
||||
onChange,
|
||||
}) => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const sideLeft =
|
||||
typeof header?.sideLeft === "function" ? header.sideLeft : null;
|
||||
const sideRight =
|
||||
typeof header?.sideRight === "function" ? header.sideRight : null;
|
||||
type Person = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
age: number;
|
||||
visits: number;
|
||||
status: string;
|
||||
progress: number;
|
||||
};
|
||||
const checkbox =
|
||||
Array.isArray(feature) && feature?.length
|
||||
? feature.includes("checkbox")
|
||||
: false;
|
||||
|
||||
const local = useLocal({
|
||||
table: null as any,
|
||||
data: [] as any[],
|
||||
dataForm: [] as any[],
|
||||
listData: [] as any[],
|
||||
sort: {} as any,
|
||||
search: null as any,
|
||||
count: 0 as any,
|
||||
addRow: (row: any) => {
|
||||
setData((prev) => [...prev, row]);
|
||||
local.data.push(row);
|
||||
local.render();
|
||||
},
|
||||
selection: {
|
||||
all: false,
|
||||
partial: [] as any[],
|
||||
},
|
||||
renderRow: (row: any) => {
|
||||
setData((prev) => [...prev, row]);
|
||||
local.data = data;
|
||||
local.render();
|
||||
},
|
||||
removeRow: (row: any) => {
|
||||
setData((prev) => prev.filter((item) => item !== row)); // Update state lokal
|
||||
local.data = local.data.filter((item: any) => item !== row); // Hapus row dari local.data
|
||||
local.render(); // Panggil render untuk memperbarui UI
|
||||
},
|
||||
reload: async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
try {
|
||||
if (Array.isArray(onLoad)) {
|
||||
local.data = onLoad;
|
||||
local.render();
|
||||
setData(onLoad);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
} else {
|
||||
const res: any = onLoad({
|
||||
search: local.search,
|
||||
sort: local.sort,
|
||||
take,
|
||||
paging: 1,
|
||||
});
|
||||
if (res instanceof Promise) {
|
||||
res.then((e) => {
|
||||
local.data = e;
|
||||
cloneListFM(e);
|
||||
local.render();
|
||||
setData(e);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
});
|
||||
} else {
|
||||
local.data = res;
|
||||
cloneListFM(res);
|
||||
local.render();
|
||||
setData(res);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(get(ex, "response.data.meta.message") || ex.message);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
const cloneListFM = (data: any[]) => {
|
||||
if (mode === "form") {
|
||||
local.dataForm = data;
|
||||
local.render();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
toast.info(
|
||||
<>
|
||||
<Loader2
|
||||
className={cx(
|
||||
"h-4 w-4 animate-spin-important",
|
||||
css`
|
||||
animation: spin 1s linear infinite !important;
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
/>
|
||||
{"Loading..."}
|
||||
</>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
);
|
||||
try {
|
||||
if (typeof onCount === "function") {
|
||||
const res = await onCount();
|
||||
local.count = res;
|
||||
|
||||
local.render();
|
||||
}
|
||||
|
||||
if (Array.isArray(onLoad)) {
|
||||
local.data = onLoad;
|
||||
cloneListFM(onLoad);
|
||||
local.render();
|
||||
setData(onLoad);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
} else {
|
||||
const res: any = await onLoad({
|
||||
search: local.search,
|
||||
sort: local.sort,
|
||||
take,
|
||||
paging: 1,
|
||||
});
|
||||
local.data = res;
|
||||
cloneListFM(res);
|
||||
local.render();
|
||||
setData(res);
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
}
|
||||
} catch (ex: any) {
|
||||
console.error(get(ex, "response.data.meta.message") || ex.message);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.dismiss();
|
||||
}, 100);
|
||||
};
|
||||
if (typeof onInit === "function") {
|
||||
onInit(local);
|
||||
}
|
||||
run();
|
||||
}, []);
|
||||
const defaultColumns: ColumnDef<Person>[] = init_column(column);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columns] = React.useState<typeof defaultColumns>(() =>
|
||||
checkbox
|
||||
? [
|
||||
{
|
||||
id: "select",
|
||||
width: 10,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onClick={(e) => {
|
||||
table.getToggleAllRowsSelectedHandler();
|
||||
const handler = table.getToggleAllRowsSelectedHandler();
|
||||
handler(e); // Pastikan ini memanggil fungsi handler yang benar
|
||||
local.selection.all = !local.selection.all;
|
||||
local.render();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const findCheck = (row: any) => {
|
||||
if (row.getIsSelected()) return true;
|
||||
const data: any = row.original;
|
||||
const res = local.selection.partial.find((e) => e === data?.id);
|
||||
return res ? true : false;
|
||||
};
|
||||
return (
|
||||
<div className="px-0.5 items-center justify-center flex flex-row">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={findCheck(row)}
|
||||
onClick={(e) => {
|
||||
const handler = row.getToggleSelectedHandler();
|
||||
handler(e); // Pastikan ini memanggil fungsi handler yang benar
|
||||
const data: any = row.original;
|
||||
const checked = local.selection.all
|
||||
? true
|
||||
: local.selection.partial.find((e) => e === data?.id);
|
||||
if (!checked) {
|
||||
local.selection.partial.push(data?.id);
|
||||
} else {
|
||||
if (
|
||||
local.selection.partial.find((e) => e === data?.id)
|
||||
) {
|
||||
local.selection.partial =
|
||||
local.selection.partial.filter(
|
||||
(e: any) => e !== data?.id
|
||||
);
|
||||
}
|
||||
local.selection.all = false;
|
||||
}
|
||||
local.render();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortable: false,
|
||||
},
|
||||
...defaultColumns,
|
||||
]
|
||||
: [...defaultColumns]
|
||||
);
|
||||
const [columnResizeMode, setColumnResizeMode] =
|
||||
React.useState<ColumnResizeMode>("onChange");
|
||||
|
||||
const [columnResizeDirection, setColumnResizeDirection] =
|
||||
React.useState<ColumnResizeDirection>("ltr");
|
||||
// Create the table and pass your options
|
||||
useEffect(() => {
|
||||
setData(local.data);
|
||||
}, [local.data.length]);
|
||||
const paginationConfig = disabledPagination
|
||||
? {}
|
||||
: {
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
};
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 20,
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: data,
|
||||
columnResizeMode,
|
||||
pageCount: Math.ceil(local.count / 20),
|
||||
manualPagination: true,
|
||||
columnResizeDirection,
|
||||
columns,
|
||||
enableRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 20, //custom default page size
|
||||
},
|
||||
},
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
...paginationConfig,
|
||||
});
|
||||
local.table = table;
|
||||
|
||||
// Manage your own state
|
||||
const [state, setState] = React.useState(table.initialState);
|
||||
|
||||
// Override the state managers for the table to your own
|
||||
table.setOptions((prev) => ({
|
||||
...prev,
|
||||
state,
|
||||
onStateChange: setState,
|
||||
debugTable: state.pagination.pageIndex > 2,
|
||||
}));
|
||||
const handleSearch = useCallback(
|
||||
debouncedHandler(() => {
|
||||
local.reload();
|
||||
}, 1000),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="tbl-wrapper flex flex-grow flex-col">
|
||||
{!disabledHeader ? (
|
||||
<div className="head-tbl-list block items-start justify-between px-4 py-4 sm:flex">
|
||||
<div className="flex flex-row items-end">
|
||||
<div className="sm:flex flex flex-col space-y-2">
|
||||
<div className="flex">
|
||||
{sideLeft ? (
|
||||
sideLeft(local)
|
||||
) : (
|
||||
<>
|
||||
<Link href={"/new"}>
|
||||
<Button className="bg-primary">
|
||||
<div className="flex items-center gap-x-0.5">
|
||||
<IconPlus className="text-xl" />
|
||||
<span className="capitalize">Add {name}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center flex-row">
|
||||
<div className="tbl-search hidden items-center sm:mb-0 sm:flex sm:divide-x sm:divide-gray-100">
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await local.reload();
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="users-search" className="sr-only">
|
||||
Search
|
||||
</Label>
|
||||
<div className="relative lg:w-56">
|
||||
<InputSearch
|
||||
// className="bg-white search text-xs "
|
||||
id="users-search"
|
||||
name="users-search"
|
||||
placeholder={`Search`}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
local.search = value;
|
||||
local.render();
|
||||
handleSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex">{sideRight ? sideRight(local) : <></>}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="container-table overflow-auto relative flex-grow flex-row">
|
||||
<div className="tbl absolute top-0 left-0 inline-block flex-grow w-full h-full align-middle">
|
||||
<div className="relative">
|
||||
<Table
|
||||
className={cx(
|
||||
"min-w-full divide-y divide-gray-200 table-bg",
|
||||
css`
|
||||
// thead th:first-child {
|
||||
// overflow: hidden;
|
||||
// border-top-left-radius: 10px; /* Sudut kiri atas */
|
||||
// border-bottom-left-radius: 10px;
|
||||
// }
|
||||
// thead th:last-child {
|
||||
// overflow: hidden;
|
||||
// border-top-right-radius: 10px; /* Sudut kiri atas */
|
||||
// border-bottom-right-radius: 10px;
|
||||
// }
|
||||
// tbody td:first-child {
|
||||
// overflow: hidden;
|
||||
// border-top-left-radius: 10px; /* Sudut kiri atas */
|
||||
// border-bottom-left-radius: 10px;
|
||||
// }
|
||||
// tbody td:last-child {
|
||||
// overflow: hidden;
|
||||
// border-top-right-radius: 10px; /* Sudut kiri atas */
|
||||
// border-bottom-right-radius: 10px;
|
||||
// }
|
||||
`,
|
||||
checkbox &&
|
||||
css`
|
||||
.table-header-tbl > th:first-child {
|
||||
width: 20px !important; /* Atur lebar sesuai kebutuhan */
|
||||
text-align: center;
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
}
|
||||
.table-row-element > td:first-child {
|
||||
width: 20px !important; /* Atur lebar sesuai kebutuhan */
|
||||
text-align: center;
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
{!disabledHeadTable ? (
|
||||
<thead className="table-head-list overflow-hidden text-md bg-primary text-white group/head text-md uppercase sticky top-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={`${headerGroup.id}`}
|
||||
className={"table-header-tbl"}
|
||||
>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const name = header.column.id;
|
||||
const col = column.find(
|
||||
(e: any) => e?.name === name
|
||||
);
|
||||
const isSort =
|
||||
name === "select"
|
||||
? false
|
||||
: typeof col?.sortable === "boolean"
|
||||
? col.sortable
|
||||
: true;
|
||||
const resize =
|
||||
name === "select"
|
||||
? false
|
||||
: typeof col?.resize === "boolean"
|
||||
? col.resize
|
||||
: true;
|
||||
return (
|
||||
<th
|
||||
{...{
|
||||
style: {
|
||||
width: !resize
|
||||
? `${col?.width}px`
|
||||
: name === "select"
|
||||
? `${5}px`
|
||||
: col?.width
|
||||
? header.getSize() < col?.width
|
||||
? `${col.width}px`
|
||||
: header.getSize()
|
||||
: header.getSize(),
|
||||
},
|
||||
}}
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cx(
|
||||
"relative px-2 py-2 text-sm py-1 uppercase",
|
||||
name === "select" &&
|
||||
css`
|
||||
max-width: 5px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={`${header.id}-label`}
|
||||
{...{
|
||||
style: col?.width
|
||||
? {
|
||||
minWidth: `${col.width}px`,
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isSort) {
|
||||
const sort = local?.sort?.[name];
|
||||
const mode =
|
||||
sort === "desc"
|
||||
? null
|
||||
: sort === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
local.sort = mode
|
||||
? {
|
||||
[name]: mode,
|
||||
}
|
||||
: {};
|
||||
local.render();
|
||||
|
||||
local.reload();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-grow flex-row flex-grow select-none items-center flex-row text-base text-nowrap",
|
||||
isSort ? " cursor-pointer" : ""
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-row items-center flex-grow text-sm capitalize",
|
||||
name === "select" ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
{isSort ? (
|
||||
<div className="flex flex-col items-center">
|
||||
{local?.sort?.[name] ? (
|
||||
<>
|
||||
{local?.sort?.[name] === "asc" ? (
|
||||
<IconCaretUpFilled
|
||||
className={cx(
|
||||
"text-xs",
|
||||
local?.sort?.[name] === "asc"
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<IconCaretDown
|
||||
className={cx(
|
||||
"text-xs",
|
||||
local?.sort?.[name] === "desc"
|
||||
? "text-white"
|
||||
: "text-primary"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<IconCaretUpDownFilled className=" text-xs text-white" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{resize &&
|
||||
headerGroup.headers.length !== index + 1 ? (
|
||||
<div
|
||||
key={`${header.id}-resizer`} // Tambahkan key unik
|
||||
{...{
|
||||
onDoubleClick: () =>
|
||||
header.column.resetSize(),
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
className: cx(
|
||||
`resizer hover:bg-second cursor-e-resize ${
|
||||
table.options.columnResizeDirection
|
||||
} ${
|
||||
header.column.getIsResizing()
|
||||
? "isResizing bg-second"
|
||||
: " bg-primary"
|
||||
}`,
|
||||
css`
|
||||
width: 1px;
|
||||
cursor: e-resize !important;
|
||||
`
|
||||
),
|
||||
style: {
|
||||
transform:
|
||||
columnResizeMode === "onEnd" &&
|
||||
header.column.getIsResizing()
|
||||
? `translateX(${
|
||||
(table.options
|
||||
.columnResizeDirection ===
|
||||
"rtl"
|
||||
? -1
|
||||
: 1) *
|
||||
(table.getState()
|
||||
.columnSizingInfo
|
||||
.deltaOffset ?? 0)
|
||||
}px)`
|
||||
: "",
|
||||
},
|
||||
}}
|
||||
></div>
|
||||
) : null}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<TableBody className="divide-y border-none ">
|
||||
{table.getRowModel().rows.map((row, idx) => {
|
||||
const fm_row =
|
||||
mode === "form" ? local.dataForm?.[idx] : null;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cx(
|
||||
disabledHoverRow ? "" : "hover:bg-gray-100",
|
||||
css`
|
||||
height: 44px;
|
||||
> td {
|
||||
vertical-align: ${align};
|
||||
}
|
||||
`,
|
||||
"border-none even:bg-linear-blue "
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => {
|
||||
const ctx = cell.getContext();
|
||||
const param = {
|
||||
row: row.original,
|
||||
name: get(ctx, "column.columnDef.accessorKey"),
|
||||
cell,
|
||||
idx,
|
||||
tbl: local,
|
||||
fm_row: fm_row,
|
||||
onChange,
|
||||
};
|
||||
const head = column.find(
|
||||
(e: any) =>
|
||||
e?.name ===
|
||||
get(ctx, "column.columnDef.accessorKey")
|
||||
);
|
||||
const renderData =
|
||||
typeof head?.renderCell === "function"
|
||||
? head.renderCell(param)
|
||||
: flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
);
|
||||
return (
|
||||
<TableCell
|
||||
className={cx(
|
||||
"text-md px-2 py-1 whitespace-nowrap text-gray-900 items-start",
|
||||
name === "select"
|
||||
? css`
|
||||
width: 5px;
|
||||
`
|
||||
: ``
|
||||
)}
|
||||
key={cell.id}
|
||||
>
|
||||
{renderData}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{!hiddenNoRow && !table.getRowModel().rows?.length && (
|
||||
<div
|
||||
className={cx(
|
||||
"flex-1 w-full absolute inset-0 flex flex-col items-center justify-center",
|
||||
css`
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`
|
||||
)}
|
||||
>
|
||||
<div className="max-w-[15%] flex flex-col items-center">
|
||||
<Sticker size={35} strokeWidth={1} />
|
||||
<div className="pt-1 text-center">No Data</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PaginationPage
|
||||
list={local}
|
||||
count={local.count}
|
||||
onNextPage={() => table.nextPage()}
|
||||
onPrevPage={() => table.previousPage()}
|
||||
disabledNextPage={!table.getCanNextPage()}
|
||||
disabledPrevPage={!table.getCanPreviousPage()}
|
||||
page={table.getState().pagination.pageIndex + 1}
|
||||
setPage={(page: any) => {
|
||||
setPagination({
|
||||
pageIndex: page,
|
||||
pageSize: 20,
|
||||
});
|
||||
}}
|
||||
countPage={table.getPageCount()}
|
||||
countData={local.data.length}
|
||||
take={take}
|
||||
onChangePage={(page: number) => {
|
||||
table.setPageIndex(page);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pagination: React.FC<any> = ({
|
||||
onNextPage,
|
||||
onPrevPage,
|
||||
disabledNextPage,
|
||||
disabledPrevPage,
|
||||
page,
|
||||
count,
|
||||
list,
|
||||
setPage,
|
||||
onChangePage,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
page: 1 as any,
|
||||
pagination: [] as any,
|
||||
});
|
||||
useEffect(() => {
|
||||
local.page = page;
|
||||
local.pagination = getPagination(page, Math.ceil(count / 20));
|
||||
local.render();
|
||||
}, [page, count]);
|
||||
return (
|
||||
<div className=" border-t border-gray-300 tbl-pagination sticky text-sm bottom-0 right-0 w-full grid grid-cols-3 gap-4 justify-end text-sm pt-2">
|
||||
<div className="flex flex-row items-center text-gray-600">
|
||||
Showing {local.page * 20 - 19} to{" "}
|
||||
{list.data?.length >= 20
|
||||
? local.page * 20
|
||||
: local.page === 1 && Math.ceil(count / 20) === 1
|
||||
? list.data?.length
|
||||
: local.page * 20 - 19 + list.data?.length}{" "}
|
||||
of {formatMoney(getNumber(count))} results
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<div>
|
||||
<nav
|
||||
className="isolate inline-flex -space-x-px flex flex-row items-center gap-x-2"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
{local.pagination.map((e: any, idx: number) => {
|
||||
return (
|
||||
<div
|
||||
key={"page_" + idx}
|
||||
onClick={() => {
|
||||
if (e?.label !== "...") {
|
||||
local.page = getNumber(e?.label);
|
||||
local.render();
|
||||
onChangePage(local.page - 1);
|
||||
setPage(local.page - 1);
|
||||
list.reload();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"text-sm px-2 py-1",
|
||||
e.active
|
||||
? "relative z-10 inline-flex items-center bg-primary font-semibold text-white rounded-md"
|
||||
: e?.label === "..."
|
||||
? "relative z-10 inline-flex items-center font-semibold text-gray-800 rounded-md"
|
||||
: "cursor-pointer relative z-10 inline-flex items-center hover:bg-gray-100 font-semibold text-gray-800 rounded-md"
|
||||
)}
|
||||
>
|
||||
{e?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-end">
|
||||
<div className="flex items-center flex-row gap-x-2 sm:mb-0 text-sm">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledPrevPage) {
|
||||
onPrevPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded p-1 ",
|
||||
disabledPrevPage
|
||||
? "text-gray-200 border-gray-200 border px-2"
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border px-2"
|
||||
)}
|
||||
>
|
||||
<IconChevronLeft className="text-sm" />
|
||||
{/* <span>Previous</span> */}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledNextPage) {
|
||||
onNextPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded p-1 ",
|
||||
disabledNextPage
|
||||
? "text-gray-200 border-gray-200 border px-2"
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border px-2"
|
||||
)}
|
||||
>
|
||||
{/* <span>Next</span> */}
|
||||
<IconChevronRight className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const PaginationPage: React.FC<any> = ({
|
||||
onNextPage,
|
||||
onPrevPage,
|
||||
disabledNextPage,
|
||||
disabledPrevPage,
|
||||
page,
|
||||
count,
|
||||
list,
|
||||
take,
|
||||
setPage,
|
||||
onChangePage,
|
||||
}) => {
|
||||
const local = useLocal({
|
||||
page: 1 as any,
|
||||
pagination: [] as any,
|
||||
});
|
||||
useEffect(() => {
|
||||
local.page = page;
|
||||
local.pagination = getPagination(page, Math.ceil(count / take));
|
||||
local.render();
|
||||
}, [page, count]);
|
||||
return (
|
||||
<div className="py-2 tbl-pagination text-sm bottom-0 right-0 w-full grid grid-cols-1 gap-4 justify-center text-sm">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<div className="flex items-center flex-row gap-x-2 sm:mb-0 text-sm">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledPrevPage) {
|
||||
onPrevPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded-full p-2 text-md",
|
||||
disabledPrevPage
|
||||
? "text-gray-200 border-gray-200 border "
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border "
|
||||
)}
|
||||
>
|
||||
<IconChevronLeft />
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<div>
|
||||
<nav
|
||||
className="isolate inline-flex -space-x-px flex flex-row items-center gap-x-2"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
{local.pagination.map((e: any, idx: number) => {
|
||||
return (
|
||||
<div
|
||||
key={"page_" + idx}
|
||||
onClick={() => {
|
||||
if (e?.label !== "...") {
|
||||
local.page = getNumber(e?.label);
|
||||
local.render();
|
||||
onChangePage(local.page - 1);
|
||||
setPage(local.page - 1);
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"text-md px-2.5 py-1",
|
||||
e.active
|
||||
? "relative z-10 inline-flex items-center bg-primary font-semibold text-white rounded-full"
|
||||
: e?.label === "..."
|
||||
? "relative z-10 inline-flex items-center font-semibold text-gray-800 rounded-full"
|
||||
: "cursor-pointer relative z-10 inline-flex items-center hover:bg-gray-100 font-semibold text-gray-800 rounded-full"
|
||||
)}
|
||||
>
|
||||
{e?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!disabledNextPage) {
|
||||
onNextPage();
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-x-2 justify-center rounded-full p-2 ",
|
||||
disabledNextPage
|
||||
? "text-gray-200 border-gray-200 border"
|
||||
: "cursor-pointer text-gray-500 hover:bg-gray-100 hover:text-gray-900 border-gray-500 border "
|
||||
)}
|
||||
>
|
||||
<IconChevronRight className="text-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getPagination = (currentPage: number, totalPages: number) => {
|
||||
const pagination: { label: string; active: boolean }[] = [];
|
||||
const maxVisible = 5; // Jumlah maksimal elemen yang ditampilkan
|
||||
const halfRange = Math.floor((maxVisible - 3) / 2);
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
// Jika total halaman lebih kecil dari batas, tampilkan semua halaman
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pagination.push({ label: i.toString(), active: i === currentPage });
|
||||
}
|
||||
} else {
|
||||
pagination.push({ label: "1", active: currentPage === 1 }); // Halaman pertama selalu ada
|
||||
|
||||
if (currentPage > halfRange + 2) {
|
||||
pagination.push({ label: "...", active: false }); // Awal titik-titik
|
||||
}
|
||||
|
||||
const startPage = Math.max(2, currentPage - halfRange);
|
||||
const endPage = Math.min(totalPages - 1, currentPage + halfRange);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pagination.push({ label: i.toString(), active: i === currentPage });
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - halfRange - 1) {
|
||||
pagination.push({ label: "...", active: false }); // Akhir titik-titik
|
||||
}
|
||||
|
||||
pagination.push({
|
||||
label: totalPages.toString(),
|
||||
active: currentPage === totalPages,
|
||||
}); // Halaman terakhir selalu ada
|
||||
}
|
||||
|
||||
return pagination;
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export const init_column = (data: any[]): any[] => {
|
||||
return data.length
|
||||
? data.map((e) => {
|
||||
return {
|
||||
accessorKey: e.name,
|
||||
...e,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
};
|
||||
|
|
@ -0,0 +1,646 @@
|
|||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { BG_COLOR, TEXT_COLOR } from "../../constants";
|
||||
import DatepickerContext from "../../contexts/DatepickerContext";
|
||||
import {
|
||||
formatDate,
|
||||
nextMonth,
|
||||
previousMonth,
|
||||
classNames as cn,
|
||||
} from "../../helpers";
|
||||
import { Period } from "../../types";
|
||||
import get from "lodash.get";
|
||||
import { getNumber } from "@/lib/utils/getNumber";
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
|
||||
interface Props {
|
||||
calendarData: {
|
||||
date: dayjs.Dayjs;
|
||||
days: {
|
||||
previous: number[];
|
||||
current: number[];
|
||||
next: number[];
|
||||
};
|
||||
};
|
||||
onClickPreviousDays: (day: number) => void;
|
||||
onClickDay: (day: number) => void;
|
||||
onClickNextDays: (day: number) => void;
|
||||
onIcon?: (day: number, date: Date, data?: any) => any;
|
||||
style?: string;
|
||||
}
|
||||
const Days: React.FC<Props> = ({
|
||||
calendarData,
|
||||
onClickPreviousDays,
|
||||
onClickDay,
|
||||
onClickNextDays,
|
||||
onIcon,
|
||||
style,
|
||||
}) => {
|
||||
// Ref
|
||||
const calendarRef = useRef(null);
|
||||
const markRef = useRef(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [heightItem, setHeightItem] = useState(0);
|
||||
const [maxItem, setMaxItem] = useState(0);
|
||||
const [width, setWidth] = useState(0);
|
||||
// Contexts
|
||||
const {
|
||||
primaryColor,
|
||||
period,
|
||||
changePeriod,
|
||||
dayHover,
|
||||
changeDayHover,
|
||||
minDate,
|
||||
maxDate,
|
||||
disabledDates,
|
||||
} = useContext(DatepickerContext);
|
||||
|
||||
// Functions
|
||||
const currentDateClass = useCallback(
|
||||
(item: number) => {
|
||||
const itemDate = `${calendarData.date.year()}-${
|
||||
calendarData.date.month() + 1
|
||||
}-${item >= 10 ? item : "0" + item}`;
|
||||
if (formatDate(dayjs()) === formatDate(dayjs(itemDate)))
|
||||
return TEXT_COLOR["500"][
|
||||
primaryColor as keyof (typeof TEXT_COLOR)["500"]
|
||||
];
|
||||
return "";
|
||||
},
|
||||
[calendarData.date, primaryColor]
|
||||
);
|
||||
|
||||
const activeDateData = useCallback(
|
||||
(day: number) => {
|
||||
const fullDay = `${calendarData.date.year()}-${
|
||||
calendarData.date.month() + 1
|
||||
}-${day}`;
|
||||
let className = "";
|
||||
|
||||
if (
|
||||
dayjs(fullDay).isSame(period.start) &&
|
||||
dayjs(fullDay).isSame(period.end)
|
||||
) {
|
||||
className = ` ${BG_COLOR["500"][primaryColor]} text-white font-medium rounded-full`;
|
||||
} else if (dayjs(fullDay).isSame(period.start)) {
|
||||
className = ` ${BG_COLOR["500"][primaryColor]} text-white font-medium ${
|
||||
dayjs(fullDay).isSame(dayHover) && !period.end
|
||||
? "rounded-full"
|
||||
: "rounded-l-full"
|
||||
}`;
|
||||
} else if (dayjs(fullDay).isSame(period.end)) {
|
||||
className = ` ${BG_COLOR["500"][primaryColor]} text-white font-medium ${
|
||||
dayjs(fullDay).isSame(dayHover) && !period.start
|
||||
? "rounded-full"
|
||||
: "rounded-r-full"
|
||||
}`;
|
||||
}
|
||||
|
||||
return {
|
||||
active:
|
||||
dayjs(fullDay).isSame(period.start) ||
|
||||
dayjs(fullDay).isSame(period.end),
|
||||
className: className,
|
||||
};
|
||||
},
|
||||
[calendarData.date, dayHover, period.end, period.start, primaryColor]
|
||||
);
|
||||
|
||||
const hoverClassByDay = useCallback(
|
||||
(day: number) => {
|
||||
let className = currentDateClass(day);
|
||||
const fullDay = `${calendarData.date.year()}-${
|
||||
calendarData.date.month() + 1
|
||||
}-${day >= 10 ? day : "0" + day}`;
|
||||
|
||||
if (period.start && period.end) {
|
||||
if (dayjs(fullDay).isBetween(period.start, period.end, "day", "[)")) {
|
||||
return ` ${BG_COLOR["100"][primaryColor]} ${currentDateClass(
|
||||
day
|
||||
)} dark:bg-white/10`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dayHover) {
|
||||
return className;
|
||||
}
|
||||
|
||||
if (
|
||||
period.start &&
|
||||
dayjs(fullDay).isBetween(period.start, dayHover, "day", "[)")
|
||||
) {
|
||||
className = ` ${BG_COLOR["100"][primaryColor]} ${currentDateClass(
|
||||
day
|
||||
)} dark:bg-white/10`;
|
||||
}
|
||||
|
||||
if (
|
||||
period.end &&
|
||||
dayjs(fullDay).isBetween(dayHover, period.end, "day", "[)")
|
||||
) {
|
||||
className = ` ${BG_COLOR["100"][primaryColor]} ${currentDateClass(
|
||||
day
|
||||
)} dark:bg-white/10`;
|
||||
}
|
||||
|
||||
if (dayHover === fullDay) {
|
||||
const bgColor = BG_COLOR["500"][primaryColor];
|
||||
className = ` transition-all duration-500 text-white font-medium ${bgColor} ${
|
||||
period.start ? "rounded-r-full" : "rounded-l-full"
|
||||
}`;
|
||||
}
|
||||
|
||||
return className;
|
||||
},
|
||||
[
|
||||
calendarData.date,
|
||||
currentDateClass,
|
||||
dayHover,
|
||||
period.end,
|
||||
period.start,
|
||||
primaryColor,
|
||||
]
|
||||
);
|
||||
|
||||
const isDateTooEarly = useCallback(
|
||||
(day: number, type: "current" | "previous" | "next") => {
|
||||
if (!minDate) {
|
||||
return false;
|
||||
}
|
||||
const object = {
|
||||
previous: previousMonth(calendarData.date),
|
||||
current: calendarData.date,
|
||||
next: nextMonth(calendarData.date),
|
||||
};
|
||||
const newDate = object[type as keyof typeof object];
|
||||
const formattedDate = newDate.set("date", day);
|
||||
return dayjs(formattedDate).isSame(dayjs(minDate), "day")
|
||||
? false
|
||||
: dayjs(formattedDate).isBefore(dayjs(minDate));
|
||||
},
|
||||
[calendarData.date, minDate]
|
||||
);
|
||||
|
||||
const isDateTooLate = useCallback(
|
||||
(day: number, type: "current" | "previous" | "next") => {
|
||||
if (!maxDate) {
|
||||
return false;
|
||||
}
|
||||
const object = {
|
||||
previous: previousMonth(calendarData.date),
|
||||
current: calendarData.date,
|
||||
next: nextMonth(calendarData.date),
|
||||
};
|
||||
const newDate = object[type as keyof typeof object];
|
||||
const formattedDate = newDate.set("date", day);
|
||||
return dayjs(formattedDate).isSame(dayjs(maxDate), "day")
|
||||
? false
|
||||
: dayjs(formattedDate).isAfter(dayjs(maxDate));
|
||||
},
|
||||
[calendarData.date, maxDate]
|
||||
);
|
||||
|
||||
const isDateDisabled = useCallback(
|
||||
(day: number, type: "current" | "previous" | "next") => {
|
||||
if (isDateTooEarly(day, type) || isDateTooLate(day, type)) {
|
||||
return true;
|
||||
}
|
||||
const object = {
|
||||
previous: previousMonth(calendarData.date),
|
||||
current: calendarData.date,
|
||||
next: nextMonth(calendarData.date),
|
||||
};
|
||||
const newDate = object[type as keyof typeof object];
|
||||
const formattedDate = `${newDate.year()}-${newDate.month() + 1}-${
|
||||
day >= 10 ? day : "0" + day
|
||||
}`;
|
||||
|
||||
if (
|
||||
!disabledDates ||
|
||||
(Array.isArray(disabledDates) && !disabledDates.length)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let matchingCount = 0;
|
||||
disabledDates?.forEach((dateRange) => {
|
||||
if (
|
||||
dayjs(formattedDate).isAfter(dateRange.startDate) &&
|
||||
dayjs(formattedDate).isBefore(dateRange.endDate)
|
||||
) {
|
||||
matchingCount++;
|
||||
}
|
||||
if (
|
||||
dayjs(formattedDate).isSame(dateRange.startDate) ||
|
||||
dayjs(formattedDate).isSame(dateRange.endDate)
|
||||
) {
|
||||
matchingCount++;
|
||||
}
|
||||
});
|
||||
return matchingCount > 0;
|
||||
},
|
||||
[calendarData.date, isDateTooEarly, isDateTooLate, disabledDates]
|
||||
);
|
||||
|
||||
const buttonClass = useCallback(
|
||||
(day: number, type: "current" | "next" | "previous") => {
|
||||
let baseClass = `calender-day flex items-center justify-center ${
|
||||
style === "custom" ? " w-6 h-6 m-1" : "w-12 h-12 lg:w-10 lg:h-10"
|
||||
} relative`;
|
||||
if (type === "current") {
|
||||
return cn(
|
||||
baseClass,
|
||||
!activeDateData(day).active
|
||||
? hoverClassByDay(day)
|
||||
: style === "custom"
|
||||
? ""
|
||||
: activeDateData(day).className,
|
||||
isDateDisabled(day, type) && "text-gray-400 cursor-not-allowed"
|
||||
);
|
||||
}
|
||||
return cn(
|
||||
baseClass,
|
||||
isDateDisabled(day, type) && "cursor-not-allowed",
|
||||
"text-gray-400"
|
||||
);
|
||||
},
|
||||
[activeDateData, hoverClassByDay, isDateDisabled]
|
||||
);
|
||||
|
||||
const checkIfHoverPeriodContainsDisabledPeriod = useCallback(
|
||||
(hoverPeriod: Period) => {
|
||||
if (!Array.isArray(disabledDates)) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < disabledDates.length; i++) {
|
||||
if (
|
||||
dayjs(hoverPeriod.start).isBefore(disabledDates[i].startDate) &&
|
||||
dayjs(hoverPeriod.end).isAfter(disabledDates[i].endDate)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[disabledDates]
|
||||
);
|
||||
|
||||
const getMetaData = useCallback(() => {
|
||||
return {
|
||||
previous: previousMonth(calendarData.date),
|
||||
current: calendarData.date,
|
||||
next: nextMonth(calendarData.date),
|
||||
};
|
||||
}, [calendarData.date]);
|
||||
|
||||
const hoverDay = useCallback(
|
||||
(day: number, type: string) => {
|
||||
const object = getMetaData();
|
||||
const newDate = object[type as keyof typeof object];
|
||||
const newHover = `${newDate.year()}-${newDate.month() + 1}-${
|
||||
day >= 10 ? day : "0" + day
|
||||
}`;
|
||||
|
||||
if (period.start && !period.end) {
|
||||
const hoverPeriod = { ...period, end: newHover };
|
||||
if (dayjs(newHover).isBefore(dayjs(period.start))) {
|
||||
hoverPeriod.start = newHover;
|
||||
hoverPeriod.end = period.start;
|
||||
if (!checkIfHoverPeriodContainsDisabledPeriod(hoverPeriod)) {
|
||||
changePeriod({
|
||||
start: null,
|
||||
end: period.start,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!checkIfHoverPeriodContainsDisabledPeriod(hoverPeriod)) {
|
||||
changeDayHover(newHover);
|
||||
}
|
||||
}
|
||||
|
||||
if (!period.start && period.end) {
|
||||
const hoverPeriod = { ...period, start: newHover };
|
||||
if (dayjs(newHover).isAfter(dayjs(period.end))) {
|
||||
hoverPeriod.start = period.end;
|
||||
hoverPeriod.end = newHover;
|
||||
if (!checkIfHoverPeriodContainsDisabledPeriod(hoverPeriod)) {
|
||||
changePeriod({
|
||||
start: period.end,
|
||||
end: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!checkIfHoverPeriodContainsDisabledPeriod(hoverPeriod)) {
|
||||
changeDayHover(newHover);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
changeDayHover,
|
||||
changePeriod,
|
||||
checkIfHoverPeriodContainsDisabledPeriod,
|
||||
getMetaData,
|
||||
period,
|
||||
]
|
||||
);
|
||||
|
||||
const handleClickDay = useCallback(
|
||||
(day: number, type: "previous" | "current" | "next") => {
|
||||
function continueClick() {
|
||||
if (type === "previous") {
|
||||
onClickPreviousDays(day);
|
||||
}
|
||||
|
||||
if (type === "current") {
|
||||
onClickDay(day);
|
||||
}
|
||||
|
||||
if (type === "next") {
|
||||
onClickNextDays(day);
|
||||
}
|
||||
}
|
||||
|
||||
if (disabledDates?.length) {
|
||||
const object = getMetaData();
|
||||
const newDate = object[type as keyof typeof object];
|
||||
const clickDay = `${newDate.year()}-${newDate.month() + 1}-${
|
||||
day >= 10 ? day : "0" + day
|
||||
}`;
|
||||
|
||||
if (period.start && !period.end) {
|
||||
dayjs(clickDay).isSame(dayHover) && continueClick();
|
||||
} else if (!period.start && period.end) {
|
||||
dayjs(clickDay).isSame(dayHover) && continueClick();
|
||||
} else {
|
||||
continueClick();
|
||||
}
|
||||
} else {
|
||||
continueClick();
|
||||
}
|
||||
},
|
||||
[
|
||||
dayHover,
|
||||
disabledDates?.length,
|
||||
getMetaData,
|
||||
onClickDay,
|
||||
onClickNextDays,
|
||||
onClickPreviousDays,
|
||||
period.end,
|
||||
period.start,
|
||||
]
|
||||
);
|
||||
const load_marker = (day: number, type: string) => {
|
||||
let fullDay = `${calendarData.date.year()}-${
|
||||
calendarData.date.month() + 1
|
||||
}-${day >= 10 ? day : "0" + day}`;
|
||||
if (type === "previous") {
|
||||
const newDate = previousMonth(calendarData.date);
|
||||
fullDay = `${newDate.year()}-${newDate.month() + 1}-${
|
||||
day >= 10 ? day : "0" + day
|
||||
}`;
|
||||
}
|
||||
if (type === "next") {
|
||||
const newDate = nextMonth(calendarData.date);
|
||||
fullDay = `${newDate.year()}-${newDate.month() + 1}-${
|
||||
day >= 10 ? day : "0" + day
|
||||
}`;
|
||||
}
|
||||
const res = new Date(fullDay);
|
||||
return typeof onIcon === "function"
|
||||
? onIcon(day, res, {
|
||||
ref: calendarRef,
|
||||
height,
|
||||
maxItem,
|
||||
width,
|
||||
heightItem,
|
||||
})
|
||||
: null;
|
||||
};
|
||||
useEffect(() => {
|
||||
if (calendarRef?.current && markRef?.current) {
|
||||
// const card = getNumber(get(calendarRef, "current.clientWidth"));
|
||||
const cardDay = calendarRef.current as any;
|
||||
const rect = cardDay.getBoundingClientRect();
|
||||
const card = getNumber(rect?.width);
|
||||
const cardHeight = getNumber(get(markRef, "current.clientHeight"));
|
||||
const heightItem = 20; // perkiraan
|
||||
console.log(card);
|
||||
setWidth(card);
|
||||
setHeight(cardHeight);
|
||||
console.log(Math.floor(cardHeight / heightItem));
|
||||
setMaxItem(Math.floor(cardHeight / heightItem));
|
||||
setHeightItem(20);
|
||||
// setMaxItem
|
||||
const day = 3;
|
||||
const fullwidth = card * 7;
|
||||
const percent = (7 / 3) * 100;
|
||||
}
|
||||
}, [calendarRef.current, markRef.current]);
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"calender-days grid grid-cols-7 ",
|
||||
style === "custom" ? "" : " my-1 gap-y-0.5",
|
||||
css`
|
||||
z-index: 0;
|
||||
.calender-grid {
|
||||
// aspect-ratio: 1 / 1;
|
||||
}
|
||||
`
|
||||
)}
|
||||
>
|
||||
{calendarData.days.previous.map((item, index) => (
|
||||
<div
|
||||
key={"prev_" + index}
|
||||
className={cx(
|
||||
"calender-grid flex flex-row",
|
||||
style === "custom"
|
||||
? "border-gray-200 hover:bg-gray-100 cursor-pointer"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => {
|
||||
if (style === "custom") handleClickDay(item, "previous");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-grow calender-day-wrap">
|
||||
{style === "custom" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={isDateDisabled(item, "previous")}
|
||||
className={`${buttonClass(item, "previous")}`}
|
||||
onMouseOver={() => {
|
||||
hoverDay(item, "previous");
|
||||
}}
|
||||
>
|
||||
<span className="relative">{item}</span>
|
||||
</button>
|
||||
<div className="flex flex-grow relative">
|
||||
{load_marker(item, "previous")}
|
||||
{/*
|
||||
{index === 1 && (
|
||||
<div
|
||||
className={cx(
|
||||
"hover:bg-gray-200 font-bold text-sm text-black px-2 absolute top-[27px] left-0 w-[196px] rounded-md",
|
||||
css`
|
||||
z-index: 1;
|
||||
`
|
||||
)}
|
||||
>
|
||||
1 more
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={isDateDisabled(item, "previous")}
|
||||
className={`${buttonClass(item, "previous")}`}
|
||||
onClick={() => handleClickDay(item, "previous")}
|
||||
onMouseOver={() => {
|
||||
hoverDay(item, "previous");
|
||||
}}
|
||||
>
|
||||
<span className="relative">
|
||||
{item}
|
||||
{load_marker(item, "previous")}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{calendarData.days.current.map((item, index) => (
|
||||
<div
|
||||
key={"current_" + index}
|
||||
ref={index === 0 ? calendarRef : null}
|
||||
className={cx(
|
||||
"calender-grid flex flex-row",
|
||||
style === "custom"
|
||||
? activeDateData(item).active
|
||||
? "bg-blue-200/75 ring-1 cursor-pointer border-gray-200"
|
||||
: "hover:bg-gray-50 cursor-pointer border-gray-200 bg-white"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => {
|
||||
if (style === "custom") handleClickDay(item, "current");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-grow calender-day-wrap">
|
||||
{style === "custom" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={isDateDisabled(item, "current")}
|
||||
className={`${buttonClass(item, "current")}`}
|
||||
// onClick={() => handleClickDay(item, "current")}
|
||||
onMouseOver={() => {
|
||||
hoverDay(item, "current");
|
||||
}}
|
||||
>
|
||||
<span className="relative">{item}</span>
|
||||
</button>
|
||||
<div
|
||||
className="flex flex-grow relative "
|
||||
ref={index === 0 ? markRef : null}
|
||||
>
|
||||
{load_marker(item, "current")}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={isDateDisabled(item, "current")}
|
||||
className={`${buttonClass(item, "current")}`}
|
||||
onClick={() => handleClickDay(item, "current")}
|
||||
onMouseOver={() => {
|
||||
hoverDay(item, "current");
|
||||
}}
|
||||
>
|
||||
<span className="relative">
|
||||
{item}
|
||||
{load_marker(item, "current")}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{calendarData.days.next.map((item, index) => (
|
||||
<div
|
||||
key={"next_" + index}
|
||||
className={cx(
|
||||
"calender-grid flex flex-row ",
|
||||
style === "custom"
|
||||
? "hover:bg-gray-100 cursor-pointer border-gray-200"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => {
|
||||
if (style === "custom") handleClickDay(item, "next");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-grow calender-day-wrap">
|
||||
{style === "custom" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={isDateDisabled(item, "next")}
|
||||
className={`${buttonClass(item, "next")}`}
|
||||
onMouseOver={() => {
|
||||
hoverDay(item, "next");
|
||||
}}
|
||||
>
|
||||
<span className="relative">{item}</span>
|
||||
</button>
|
||||
<div className="flex flex-grow relative ">
|
||||
{load_marker(item, "next")}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
disabled={isDateDisabled(item, "next")}
|
||||
className={`${buttonClass(item, "next")}`}
|
||||
onClick={() => handleClickDay(item, "next")}
|
||||
onMouseOver={() => {
|
||||
hoverDay(item, "next");
|
||||
}}
|
||||
>
|
||||
<span className="relative">
|
||||
{item}
|
||||
{load_marker(item, "next")}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Days;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { MONTHS } from "../../constants";
|
||||
import DatepickerContext from "../../contexts/DatepickerContext";
|
||||
import { loadLanguageModule } from "../../helpers";
|
||||
import { RoundedButton } from "../utils";
|
||||
|
||||
interface Props {
|
||||
currentMonth: number;
|
||||
clickMonth: (month: number) => void;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const Months: React.FC<Props> = ({ currentMonth, clickMonth, style }) => {
|
||||
const { i18n } = useContext(DatepickerContext);
|
||||
loadLanguageModule(i18n);
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"w-full grid gap-2 mt-2",
|
||||
style === "custom" ? "uppercase grid-cols-2 p-4" : "grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{MONTHS.map((item) => (
|
||||
<RoundedButton
|
||||
key={item}
|
||||
padding="py-3"
|
||||
onClick={() => {
|
||||
clickMonth(item);
|
||||
}}
|
||||
active={currentMonth === item}
|
||||
style={style}
|
||||
>
|
||||
{style === "custom" ? (
|
||||
<div className="px-2 py-1">
|
||||
{dayjs(`2022-${item}-01`).locale(i18n).format("MMMM")}
|
||||
</div>
|
||||
) : (
|
||||
<>{dayjs(`2022-${item}-01`).locale(i18n).format("MMM")}</>
|
||||
)}
|
||||
</RoundedButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Months;
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
|
||||
import { DAYS } from "../../constants";
|
||||
import DatepickerContext from "../../contexts/DatepickerContext";
|
||||
import { loadLanguageModule, shortString, ucFirst } from "../../helpers";
|
||||
interface Props {
|
||||
style?: string;
|
||||
}
|
||||
const Week: React.FC<Props> = ({ style }) => {
|
||||
const { i18n, startWeekOn } = useContext(DatepickerContext);
|
||||
loadLanguageModule(i18n);
|
||||
const startDateModifier = useMemo(() => {
|
||||
if (startWeekOn) {
|
||||
switch (startWeekOn) {
|
||||
case "mon":
|
||||
return 1;
|
||||
case "tue":
|
||||
return 2;
|
||||
case "wed":
|
||||
return 3;
|
||||
case "thu":
|
||||
return 4;
|
||||
case "fri":
|
||||
return 5;
|
||||
case "sat":
|
||||
return 6;
|
||||
case "sun":
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [startWeekOn]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
" grid grid-cols-7 border-b dark:border-gray-700",
|
||||
style === "custom"
|
||||
? "sticky top-0 bg-white z-99 border-gray-200"
|
||||
: "border-gray-300 py-2",
|
||||
style === "custom" &&
|
||||
css`
|
||||
z-index: 99;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{DAYS.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className={cx(
|
||||
"tracking-wide text-gray-500 text-center",
|
||||
style === "custom" && " border-r border-gray-200 py-2"
|
||||
)}
|
||||
>
|
||||
{ucFirst(
|
||||
shortString(
|
||||
dayjs(`2022-11-${6 + (item + startDateModifier)}`)
|
||||
.locale(i18n)
|
||||
.format("ddd")
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Week;
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useContext } from "react";
|
||||
|
||||
import { generateArrayNumber } from "../../helpers";
|
||||
import { RoundedButton } from "../utils";
|
||||
|
||||
import DatepickerContext from "../../contexts/DatepickerContext";
|
||||
|
||||
interface Props {
|
||||
year: number;
|
||||
currentYear: number;
|
||||
minYear: number | null;
|
||||
maxYear: number | null;
|
||||
clickYear: (data: number) => void;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const Years: React.FC<Props> = ({
|
||||
year,
|
||||
currentYear,
|
||||
minYear,
|
||||
maxYear,
|
||||
clickYear,
|
||||
style,
|
||||
}) => {
|
||||
const { dateLooking } = useContext(DatepickerContext);
|
||||
|
||||
let startDate = 0;
|
||||
let endDate = 0;
|
||||
|
||||
switch (dateLooking) {
|
||||
case "backward":
|
||||
startDate = year - 11;
|
||||
endDate = year;
|
||||
break;
|
||||
case "middle":
|
||||
startDate = year - 4;
|
||||
endDate = year + 7;
|
||||
break;
|
||||
case "forward":
|
||||
default:
|
||||
startDate = year;
|
||||
endDate = year + 11;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className=" w-full grid grid-cols-2 gap-2 mt-2">
|
||||
{generateArrayNumber(startDate, endDate).map((item, index) => (
|
||||
<RoundedButton
|
||||
key={index}
|
||||
padding="py-3"
|
||||
onClick={() => {
|
||||
clickYear(item);
|
||||
}}
|
||||
active={currentYear === item}
|
||||
disabled={
|
||||
(maxYear !== null && item > maxYear) ||
|
||||
(minYear !== null && item < minYear)
|
||||
}
|
||||
>
|
||||
<>{item}</>
|
||||
</RoundedButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Years;
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { CALENDAR_SIZE, DATE_FORMAT } from "../../constants";
|
||||
import DatepickerContext from "../../contexts/DatepickerContext";
|
||||
import {
|
||||
formatDate,
|
||||
getDaysInMonth,
|
||||
getFirstDayInMonth,
|
||||
getFirstDaysInMonth,
|
||||
getLastDaysInMonth,
|
||||
getNumberOfDay,
|
||||
loadLanguageModule,
|
||||
nextMonth,
|
||||
previousMonth,
|
||||
} from "../../helpers";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleChevronLeftIcon,
|
||||
DoubleChevronRightIcon,
|
||||
RoundedButton,
|
||||
} from "../utils";
|
||||
|
||||
import Days from "./Days";
|
||||
import Months from "./Months";
|
||||
import Week from "./Week";
|
||||
import Years from "./Years";
|
||||
|
||||
import { DateType } from "../../types";
|
||||
|
||||
interface Props {
|
||||
date: dayjs.Dayjs;
|
||||
minDate?: DateType | null;
|
||||
maxDate?: DateType | null;
|
||||
onClickPrevious: () => void;
|
||||
onClickNext: () => void;
|
||||
changeMonth: (month: number) => void;
|
||||
changeYear: (year: number) => void;
|
||||
mode?: "monthly" | "daily";
|
||||
onMark?: (day: number, date: Date, data?: any) => any;
|
||||
style?: "custom" | "prasi";
|
||||
onLoad?: (e?: any) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const Calendar: React.FC<Props> = ({
|
||||
date,
|
||||
minDate,
|
||||
maxDate,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
changeMonth,
|
||||
changeYear,
|
||||
onMark,
|
||||
mode = "daily",
|
||||
style = "prasi",
|
||||
onLoad,
|
||||
}) => {
|
||||
// Contexts
|
||||
const {
|
||||
period,
|
||||
changePeriod,
|
||||
changeDayHover,
|
||||
showFooter,
|
||||
changeDatepickerValue,
|
||||
hideDatepicker,
|
||||
asSingle,
|
||||
i18n,
|
||||
startWeekOn,
|
||||
input,
|
||||
} = useContext(DatepickerContext);
|
||||
loadLanguageModule(i18n);
|
||||
|
||||
// States
|
||||
const [showMonths, setShowMonths] = useState(false);
|
||||
const [showYears, setShowYears] = useState(false);
|
||||
const [year, setYear] = useState(date.year());
|
||||
const [openPopover, setOpenPopover] = useState(false);
|
||||
const [openPopoverYear, setOpenPopoverYear] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode === "monthly") {
|
||||
setShowMonths(true);
|
||||
hideYears();
|
||||
}
|
||||
}, []);
|
||||
// Functions
|
||||
const previous = useCallback(() => {
|
||||
return getLastDaysInMonth(
|
||||
previousMonth(date),
|
||||
getNumberOfDay(getFirstDayInMonth(date).ddd, startWeekOn)
|
||||
);
|
||||
}, [date, startWeekOn]);
|
||||
const previousDate = useCallback(() => {
|
||||
const day = getLastDaysInMonth(
|
||||
previousMonth(date),
|
||||
getNumberOfDay(getFirstDayInMonth(date).ddd, startWeekOn)
|
||||
);
|
||||
}, [date, startWeekOn]);
|
||||
const current = useCallback(() => {
|
||||
return getDaysInMonth(formatDate(date));
|
||||
}, [date]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
return getFirstDaysInMonth(
|
||||
previousMonth(date),
|
||||
CALENDAR_SIZE - (previous().length + current().length)
|
||||
);
|
||||
}, [current, date, previous]);
|
||||
|
||||
const hideMonths = useCallback(() => {
|
||||
showMonths && setShowMonths(false);
|
||||
}, [showMonths]);
|
||||
|
||||
const hideYears = useCallback(() => {
|
||||
showYears && setShowYears(false);
|
||||
}, [showYears]);
|
||||
|
||||
const clickMonth = useCallback(
|
||||
(month: number) => {
|
||||
setTimeout(() => {
|
||||
changeMonth(month);
|
||||
if (mode === "daily") {
|
||||
setShowMonths(!showMonths);
|
||||
} else {
|
||||
hideDatepicker();
|
||||
clickDay(1, month, date.year());
|
||||
}
|
||||
}, 250);
|
||||
},
|
||||
[changeMonth, showMonths]
|
||||
);
|
||||
|
||||
const clickYear = useCallback(
|
||||
(year: number) => {
|
||||
setTimeout(() => {
|
||||
changeYear(year);
|
||||
setShowYears(!showYears);
|
||||
if (mode === "monthly") {
|
||||
setShowMonths(true);
|
||||
clickDay(1, date.month() + 1, year);
|
||||
}
|
||||
}, 250);
|
||||
},
|
||||
[changeYear, showYears]
|
||||
);
|
||||
|
||||
const clickDay = useCallback(
|
||||
(day: number, month = date.month() + 1, year = date.year()) => {
|
||||
const fullDay = `${year}-${month}-${day}`;
|
||||
let newStart;
|
||||
let newEnd = null;
|
||||
function chosePeriod(start: string, end: string) {
|
||||
const ipt = input?.current;
|
||||
changeDatepickerValue(
|
||||
{
|
||||
startDate: dayjs(start).format(DATE_FORMAT),
|
||||
endDate: dayjs(end).format(DATE_FORMAT),
|
||||
},
|
||||
ipt
|
||||
);
|
||||
hideDatepicker();
|
||||
}
|
||||
|
||||
if (period.start && period.end) {
|
||||
if (changeDayHover) {
|
||||
changeDayHover(null);
|
||||
}
|
||||
changePeriod({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
}
|
||||
|
||||
if ((!period.start && !period.end) || (period.start && period.end)) {
|
||||
if (!period.start && !period.end) {
|
||||
changeDayHover(fullDay);
|
||||
}
|
||||
newStart = fullDay;
|
||||
if (asSingle) {
|
||||
newEnd = fullDay;
|
||||
chosePeriod(fullDay, fullDay);
|
||||
}
|
||||
} else {
|
||||
if (period.start && !period.end) {
|
||||
// start not null
|
||||
// end null
|
||||
const condition =
|
||||
dayjs(fullDay).isSame(dayjs(period.start)) ||
|
||||
dayjs(fullDay).isAfter(dayjs(period.start));
|
||||
newStart = condition ? period.start : fullDay;
|
||||
newEnd = condition ? fullDay : period.start;
|
||||
} else {
|
||||
// Start null
|
||||
// End not null
|
||||
const condition =
|
||||
dayjs(fullDay).isSame(dayjs(period.end)) ||
|
||||
dayjs(fullDay).isBefore(dayjs(period.end));
|
||||
newStart = condition ? fullDay : period.start;
|
||||
newEnd = condition ? period.end : fullDay;
|
||||
}
|
||||
|
||||
if (!showFooter) {
|
||||
if (newStart && newEnd) {
|
||||
chosePeriod(newStart, newEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(newEnd && newStart) || showFooter) {
|
||||
changePeriod({
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
asSingle,
|
||||
changeDatepickerValue,
|
||||
changeDayHover,
|
||||
changePeriod,
|
||||
date,
|
||||
hideDatepicker,
|
||||
period.end,
|
||||
period.start,
|
||||
showFooter,
|
||||
input,
|
||||
]
|
||||
);
|
||||
|
||||
const clickPreviousDays = useCallback(
|
||||
(day: number) => {
|
||||
const newDate = previousMonth(date);
|
||||
clickDay(day, newDate.month() + 1, newDate.year());
|
||||
onClickPrevious();
|
||||
},
|
||||
[clickDay, date, onClickPrevious]
|
||||
);
|
||||
|
||||
const clickNextDays = useCallback(
|
||||
(day: number) => {
|
||||
const newDate = nextMonth(date);
|
||||
clickDay(day, newDate.month() + 1, newDate.year());
|
||||
onClickNext();
|
||||
},
|
||||
[clickDay, date, onClickNext]
|
||||
);
|
||||
|
||||
// UseEffects & UseLayoutEffect
|
||||
useEffect(() => {
|
||||
setYear(date.year());
|
||||
}, [date]);
|
||||
|
||||
const getMonth = (month?: string) => {
|
||||
const value: any = date;
|
||||
const currentDate: any = new Date(value);
|
||||
const previousMonthDate = new Date(currentDate);
|
||||
previousMonthDate.setDate(1);
|
||||
switch (month) {
|
||||
case "before":
|
||||
previousMonthDate.setMonth(currentDate.getMonth() - 1);
|
||||
break;
|
||||
case "after":
|
||||
previousMonthDate.setMonth(currentDate.getMonth() + 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return previousMonthDate;
|
||||
};
|
||||
// Variables
|
||||
const calendarData = useMemo(() => {
|
||||
const data = {
|
||||
previous: previous(),
|
||||
current: current(),
|
||||
next: next(),
|
||||
};
|
||||
const result = {
|
||||
date: date,
|
||||
days: data,
|
||||
time: {
|
||||
previous: data?.previous?.length
|
||||
? data.previous.map((e) => {
|
||||
return new Date(
|
||||
getMonth("before").getFullYear(),
|
||||
getMonth("before").getMonth(),
|
||||
e
|
||||
);
|
||||
})
|
||||
: [],
|
||||
current: data?.current?.length
|
||||
? data.current.map((e) => {
|
||||
return new Date(
|
||||
getMonth().getFullYear(),
|
||||
getMonth().getMonth(),
|
||||
e
|
||||
);
|
||||
})
|
||||
: [],
|
||||
next: data?.next?.length
|
||||
? data.next.map((e) => {
|
||||
return new Date(
|
||||
getMonth("after").getFullYear(),
|
||||
getMonth("after").getMonth(),
|
||||
e
|
||||
);
|
||||
})
|
||||
: [],
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}, [current, date, next, previous]);
|
||||
useEffect(() => {
|
||||
if (typeof onLoad === "function") {
|
||||
const run = async () => {
|
||||
if (typeof onLoad === "function") {
|
||||
const param = dayjs(formatDate(date)).toDate();
|
||||
await onLoad({
|
||||
date: param,
|
||||
calender: calendarData,
|
||||
});
|
||||
}
|
||||
};
|
||||
run();
|
||||
}
|
||||
}, [calendarData, date]);
|
||||
const minYear = React.useMemo(
|
||||
() => (minDate && dayjs(minDate).isValid() ? dayjs(minDate).year() : null),
|
||||
[minDate]
|
||||
);
|
||||
const maxYear = React.useMemo(
|
||||
() => (maxDate && dayjs(maxDate).isValid() ? dayjs(maxDate).year() : null),
|
||||
[maxDate]
|
||||
);
|
||||
const isCustom = style === "custom";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"w-full md:w-[296px] md:min-w-[296px] calender",
|
||||
isCustom && "flex-grow"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"flex items-stretch ",
|
||||
isCustom ? "" : "space-x-1.5 px-2 py-1.5 flex-col",
|
||||
css`
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
`
|
||||
)}
|
||||
>
|
||||
{style === "custom" ? (
|
||||
<div className="flex flex-row items-center px-2 py-2 justify-between w-full">
|
||||
<div className="flex flex-row gap-x-2 items-center">
|
||||
<div className="flex flex-row gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClickPrevious}
|
||||
className="flex items-center justify-center rounded-l-md py-2 pl-3 pr-4 text-gray-400 hover:text-gray-500 focus:relative md:w-9 md:px-2 md:hover:bg-gray-50"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClickNext}
|
||||
className="flex items-center justify-center rounded-r-md py-2 pl-4 pr-3 text-gray-400 hover:text-gray-500 focus:relative md:w-9 md:px-2 md:hover:bg-gray-50"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 capitalize flex flex-row">
|
||||
{calendarData.date.locale(i18n).format("MMMM")}{" "}
|
||||
{calendarData.date.year()}
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center">
|
||||
{!showMonths && !showYears && (
|
||||
<div className="flex-none flex-row flex items-center">
|
||||
<RoundedButton roundedFull={true} onClick={onClickPrevious}>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</RoundedButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showYears && (
|
||||
<div className="flex-none flex-row flex items-center">
|
||||
<RoundedButton
|
||||
roundedFull={true}
|
||||
onClick={() => {
|
||||
setYear(year - 12);
|
||||
}}
|
||||
>
|
||||
<DoubleChevronLeftIcon className="h-5 w-5" />
|
||||
</RoundedButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className=" flex flex-1 items-stretch space-x-1.5">
|
||||
<div className="w-1/2 flex items-stretch">
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
setShowMonths(!showMonths);
|
||||
hideYears();
|
||||
}}
|
||||
>
|
||||
<>{calendarData.date.locale(i18n).format("MMM")}</>
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 flex items-stretch">
|
||||
<RoundedButton
|
||||
onClick={() => {
|
||||
setShowYears(!showYears);
|
||||
hideMonths();
|
||||
}}
|
||||
>
|
||||
<div className="">{calendarData.date.year()}</div>
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showYears && (
|
||||
<div className="flex-none flex-row flex items-center">
|
||||
<RoundedButton
|
||||
roundedFull={true}
|
||||
onClick={() => {
|
||||
setYear(year + 12);
|
||||
}}
|
||||
>
|
||||
<DoubleChevronRightIcon className="h-5 w-5" />
|
||||
</RoundedButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showMonths && !showYears && (
|
||||
<div className="flex-none flex-row flex items-center">
|
||||
<RoundedButton roundedFull={true} onClick={onClickNext}>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</RoundedButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
isCustom ? "flex-grow" : "min-h-[285px]",
|
||||
"mt-0.5 calender-body"
|
||||
)}
|
||||
>
|
||||
{showMonths && (
|
||||
<Months
|
||||
currentMonth={calendarData.date.month() + 1}
|
||||
clickMonth={clickMonth}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showYears && (
|
||||
<Years
|
||||
year={year}
|
||||
minYear={minYear}
|
||||
maxYear={maxYear}
|
||||
currentYear={calendarData.date.year()}
|
||||
clickYear={clickYear}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showMonths && !showYears && (
|
||||
<>
|
||||
<Week style={style} />
|
||||
|
||||
<Days
|
||||
calendarData={calendarData}
|
||||
onClickPreviousDays={clickPreviousDays}
|
||||
onClickDay={clickDay}
|
||||
onClickNextDays={clickNextDays}
|
||||
style={style}
|
||||
onIcon={(day, date, data) => {
|
||||
if (typeof onMark === "function") {
|
||||
return onMark(day, date, data);
|
||||
}
|
||||
return <></>;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import Calendar from "./Calendar";
|
||||
import Footer from "./Footer";
|
||||
import Input from "./Input";
|
||||
import Shortcuts from "./Shortcuts";
|
||||
import { COLORS, DATE_FORMAT, DEFAULT_COLOR, LANGUAGE } from "../constants";
|
||||
import DatepickerContext from "../contexts/DatepickerContext";
|
||||
import { formatDate, nextMonth, previousMonth } from "../helpers";
|
||||
import useOnClickOutside from "../hooks";
|
||||
import { Period, DatepickerType, ColorKeys } from "../types";
|
||||
|
||||
import { VerticalDash } from "./utils";
|
||||
import { useLocal } from "@/lib/utils/use-local";
|
||||
import { Popover } from "@/components/Popover/Popover";
|
||||
|
||||
const Datepicker: React.FC<DatepickerType> = ({
|
||||
primaryColor = "blue",
|
||||
value = null,
|
||||
onChange,
|
||||
useRange = true,
|
||||
showFooter = false,
|
||||
showShortcuts = false,
|
||||
configs = undefined,
|
||||
asSingle = false,
|
||||
placeholder = null,
|
||||
separator = "~",
|
||||
startFrom = null,
|
||||
i18n = LANGUAGE,
|
||||
disabled = false,
|
||||
inputClassName = null,
|
||||
containerClassName = null,
|
||||
toggleClassName = null,
|
||||
toggleIcon = undefined,
|
||||
displayFormat = DATE_FORMAT,
|
||||
readOnly = false,
|
||||
minDate = null,
|
||||
maxDate = null,
|
||||
dateLooking = "forward",
|
||||
disabledDates = null,
|
||||
inputId,
|
||||
inputName,
|
||||
startWeekOn = "sun",
|
||||
classNames = undefined,
|
||||
popoverDirection = undefined,
|
||||
mode = "daily",
|
||||
}) => {
|
||||
const local = useLocal({ open: false as boolean });
|
||||
// Ref
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const calendarContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const arrowRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// State
|
||||
const [firstDate, setFirstDate] = useState<dayjs.Dayjs>(
|
||||
startFrom && dayjs(startFrom).isValid() ? dayjs(startFrom) : dayjs()
|
||||
);
|
||||
const [secondDate, setSecondDate] = useState<dayjs.Dayjs>(
|
||||
nextMonth(firstDate)
|
||||
);
|
||||
const [period, setPeriod] = useState<Period>({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
const [dayHover, setDayHover] = useState<string | null>(null);
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
const [inputRef, setInputRef] = useState(React.createRef<HTMLInputElement>());
|
||||
|
||||
// Custom Hooks use
|
||||
useOnClickOutside(calendarContainerRef, () => {
|
||||
const container = calendarContainerRef.current;
|
||||
if (container) {
|
||||
hideDatepicker();
|
||||
}
|
||||
});
|
||||
|
||||
// Functions
|
||||
const hideDatepicker = useCallback(() => {
|
||||
local.open = false;
|
||||
local.render();
|
||||
}, []);
|
||||
|
||||
/* Start First */
|
||||
const firstGotoDate = useCallback(
|
||||
(date: dayjs.Dayjs) => {
|
||||
const newDate = dayjs(formatDate(date));
|
||||
const reformatDate = dayjs(formatDate(secondDate));
|
||||
if (newDate.isSame(reformatDate) || newDate.isAfter(reformatDate)) {
|
||||
setSecondDate(nextMonth(date));
|
||||
}
|
||||
setFirstDate(date);
|
||||
},
|
||||
[secondDate]
|
||||
);
|
||||
|
||||
const previousMonthFirst = useCallback(() => {
|
||||
setFirstDate(previousMonth(firstDate));
|
||||
}, [firstDate]);
|
||||
|
||||
const nextMonthFirst = useCallback(() => {
|
||||
firstGotoDate(nextMonth(firstDate));
|
||||
}, [firstDate, firstGotoDate]);
|
||||
|
||||
const changeFirstMonth = useCallback(
|
||||
(month: number) => {
|
||||
firstGotoDate(
|
||||
dayjs(`${firstDate.year()}-${month < 10 ? "0" : ""}${month}-01`)
|
||||
);
|
||||
},
|
||||
[firstDate, firstGotoDate]
|
||||
);
|
||||
|
||||
const changeFirstYear = useCallback(
|
||||
(year: number) => {
|
||||
firstGotoDate(dayjs(`${year}-${firstDate.month() + 1}-01`));
|
||||
},
|
||||
[firstDate, firstGotoDate]
|
||||
);
|
||||
/* End First */
|
||||
|
||||
/* Start Second */
|
||||
const secondGotoDate = useCallback(
|
||||
(date: dayjs.Dayjs) => {
|
||||
const newDate = dayjs(formatDate(date, displayFormat));
|
||||
const reformatDate = dayjs(formatDate(firstDate, displayFormat));
|
||||
if (newDate.isSame(reformatDate) || newDate.isBefore(reformatDate)) {
|
||||
setFirstDate(previousMonth(date));
|
||||
}
|
||||
setSecondDate(date);
|
||||
},
|
||||
[firstDate, displayFormat]
|
||||
);
|
||||
|
||||
const previousMonthSecond = useCallback(() => {
|
||||
secondGotoDate(previousMonth(secondDate));
|
||||
}, [secondDate, secondGotoDate]);
|
||||
|
||||
const nextMonthSecond = useCallback(() => {
|
||||
setSecondDate(nextMonth(secondDate));
|
||||
}, [secondDate]);
|
||||
|
||||
const changeSecondMonth = useCallback(
|
||||
(month: number) => {
|
||||
secondGotoDate(
|
||||
dayjs(`${secondDate.year()}-${month < 10 ? "0" : ""}${month}-01`)
|
||||
);
|
||||
},
|
||||
[secondDate, secondGotoDate]
|
||||
);
|
||||
|
||||
const changeSecondYear = useCallback(
|
||||
(year: number) => {
|
||||
secondGotoDate(dayjs(`${year}-${secondDate.month() + 1}-01`));
|
||||
},
|
||||
[secondDate, secondGotoDate]
|
||||
);
|
||||
/* End Second */
|
||||
|
||||
// UseEffects & UseLayoutEffect
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const calendarContainer = calendarContainerRef.current;
|
||||
const arrow = arrowRef.current;
|
||||
|
||||
if (container && calendarContainer && arrow) {
|
||||
const detail = container.getBoundingClientRect();
|
||||
const screenCenter = window.innerWidth / 2;
|
||||
const containerCenter = (detail.right - detail.x) / 2 + detail.x;
|
||||
|
||||
if (containerCenter > screenCenter) {
|
||||
arrow.classList.add("right-0");
|
||||
arrow.classList.add("mr-3.5");
|
||||
calendarContainer.classList.add("right-0");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value && value.startDate && value.endDate) {
|
||||
const startDate = dayjs(value.startDate);
|
||||
const endDate = dayjs(value.endDate);
|
||||
const validDate = startDate.isValid() && endDate.isValid();
|
||||
const condition =
|
||||
validDate && (startDate.isSame(endDate) || startDate.isBefore(endDate));
|
||||
if (condition) {
|
||||
setPeriod({
|
||||
start: formatDate(startDate),
|
||||
end: formatDate(endDate),
|
||||
});
|
||||
setInputText(
|
||||
`${formatDate(startDate, displayFormat)}${
|
||||
asSingle
|
||||
? ""
|
||||
: ` ${separator} ${formatDate(endDate, displayFormat)}`
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (value && value.startDate === null && value.endDate === null) {
|
||||
setPeriod({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
setInputText("");
|
||||
}
|
||||
}, [asSingle, value, displayFormat, separator]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startFrom && dayjs(startFrom).isValid()) {
|
||||
const startDate = value?.startDate;
|
||||
const endDate = value?.endDate;
|
||||
if (startDate && dayjs(startDate).isValid()) {
|
||||
setFirstDate(dayjs(startDate));
|
||||
if (!asSingle) {
|
||||
if (
|
||||
endDate &&
|
||||
dayjs(endDate).isValid() &&
|
||||
dayjs(endDate).startOf("month").isAfter(dayjs(startDate))
|
||||
) {
|
||||
setSecondDate(dayjs(endDate));
|
||||
} else {
|
||||
setSecondDate(nextMonth(dayjs(startDate)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setFirstDate(dayjs(startFrom));
|
||||
setSecondDate(nextMonth(dayjs(startFrom)));
|
||||
}
|
||||
}
|
||||
}, [asSingle, startFrom, value]);
|
||||
|
||||
// Variables
|
||||
const safePrimaryColor = useMemo(() => {
|
||||
if (COLORS.includes(primaryColor)) {
|
||||
return primaryColor as ColorKeys;
|
||||
}
|
||||
return DEFAULT_COLOR;
|
||||
}, [primaryColor]);
|
||||
const contextValues = useMemo(() => {
|
||||
return {
|
||||
asSingle,
|
||||
primaryColor: safePrimaryColor,
|
||||
configs,
|
||||
calendarContainer: calendarContainerRef,
|
||||
arrowContainer: arrowRef,
|
||||
hideDatepicker,
|
||||
period,
|
||||
changePeriod: (newPeriod: Period) => setPeriod(newPeriod),
|
||||
dayHover,
|
||||
changeDayHover: (newDay: string | null) => setDayHover(newDay),
|
||||
inputText,
|
||||
changeInputText: (newText: string) => setInputText(newText),
|
||||
updateFirstDate: (newDate: dayjs.Dayjs) => firstGotoDate(newDate),
|
||||
changeDatepickerValue: onChange,
|
||||
showFooter,
|
||||
placeholder,
|
||||
separator,
|
||||
i18n,
|
||||
value,
|
||||
disabled,
|
||||
inputClassName,
|
||||
containerClassName,
|
||||
toggleClassName,
|
||||
toggleIcon,
|
||||
readOnly,
|
||||
displayFormat,
|
||||
minDate,
|
||||
maxDate,
|
||||
dateLooking,
|
||||
disabledDates,
|
||||
inputId,
|
||||
inputName,
|
||||
startWeekOn,
|
||||
classNames,
|
||||
onChange,
|
||||
input: inputRef,
|
||||
popoverDirection,
|
||||
};
|
||||
}, [
|
||||
asSingle,
|
||||
safePrimaryColor,
|
||||
configs,
|
||||
hideDatepicker,
|
||||
period,
|
||||
dayHover,
|
||||
inputText,
|
||||
onChange,
|
||||
showFooter,
|
||||
placeholder,
|
||||
separator,
|
||||
i18n,
|
||||
value,
|
||||
disabled,
|
||||
inputClassName,
|
||||
containerClassName,
|
||||
toggleClassName,
|
||||
toggleIcon,
|
||||
readOnly,
|
||||
displayFormat,
|
||||
minDate,
|
||||
maxDate,
|
||||
dateLooking,
|
||||
disabledDates,
|
||||
inputId,
|
||||
inputName,
|
||||
startWeekOn,
|
||||
classNames,
|
||||
inputRef,
|
||||
popoverDirection,
|
||||
firstGotoDate,
|
||||
]);
|
||||
|
||||
const containerClassNameOverload = useMemo(() => {
|
||||
const defaultContainerClassName = "relative w-full text-gray-700";
|
||||
return typeof containerClassName === "function"
|
||||
? containerClassName(defaultContainerClassName)
|
||||
: typeof containerClassName === "string" && containerClassName !== ""
|
||||
? containerClassName
|
||||
: defaultContainerClassName;
|
||||
}, [containerClassName]);
|
||||
|
||||
return (
|
||||
<DatepickerContext.Provider value={contextValues}>
|
||||
<Popover
|
||||
classNameTrigger={"w-full"}
|
||||
arrow={false}
|
||||
className="rounded-md"
|
||||
onOpenChange={(open: any) => {
|
||||
if (!disabled) {
|
||||
local.open = open;
|
||||
local.render();
|
||||
}
|
||||
}}
|
||||
open={local.open}
|
||||
content={
|
||||
<div className={cx("text-md 2xl:text-md")} ref={calendarContainerRef}>
|
||||
<div className="flex flex-col lg:flex-row py-1">
|
||||
{showShortcuts && <Shortcuts />}
|
||||
|
||||
<div
|
||||
className={`flex items-stretch flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-1.5 ${
|
||||
showShortcuts ? "md:pl-2" : "md:pl-1"
|
||||
} pr-2 lg:pr-1`}
|
||||
>
|
||||
<Calendar
|
||||
date={firstDate}
|
||||
onClickPrevious={previousMonthFirst}
|
||||
onClickNext={nextMonthFirst}
|
||||
changeMonth={changeFirstMonth}
|
||||
changeYear={changeFirstYear}
|
||||
mode={mode}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
/>
|
||||
|
||||
{useRange && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<VerticalDash />
|
||||
</div>
|
||||
|
||||
<Calendar
|
||||
date={secondDate}
|
||||
onClickPrevious={previousMonthSecond}
|
||||
onClickNext={nextMonthSecond}
|
||||
changeMonth={changeSecondMonth}
|
||||
changeYear={changeSecondYear}
|
||||
mode={mode}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFooter && <Footer />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={containerClassNameOverload}>
|
||||
<Input setContextRef={setInputRef} />
|
||||
</div>
|
||||
</Popover>
|
||||
</DatepickerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Datepicker;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useContext } from "react";
|
||||
|
||||
import { DATE_FORMAT } from "../constants";
|
||||
import DatepickerContext from "../contexts/DatepickerContext";
|
||||
|
||||
import { PrimaryButton, SecondaryButton } from "./utils";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
// Contexts
|
||||
const { hideDatepicker, period, changeDatepickerValue, configs, classNames } =
|
||||
useContext(DatepickerContext);
|
||||
|
||||
// Functions
|
||||
const getClassName = useCallback(() => {
|
||||
if (
|
||||
typeof classNames !== "undefined" &&
|
||||
typeof classNames?.footer === "function"
|
||||
) {
|
||||
return classNames.footer();
|
||||
}
|
||||
|
||||
return " flex items-center justify-end pb-2.5 pt-3 border-t border-gray-300 dark:border-gray-700";
|
||||
}, [classNames]);
|
||||
|
||||
return (
|
||||
<div className={getClassName()}>
|
||||
<div className="w-full md:w-auto flex items-center justify-center space-x-3">
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
hideDatepicker();
|
||||
}}
|
||||
>
|
||||
<>{configs?.footer?.cancel ? configs.footer.cancel : "Cancel"}</>
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (period.start && period.end) {
|
||||
changeDatepickerValue({
|
||||
startDate: dayjs(period.start).format(DATE_FORMAT),
|
||||
endDate: dayjs(period.end).format(DATE_FORMAT),
|
||||
});
|
||||
hideDatepicker();
|
||||
}
|
||||
}}
|
||||
disabled={!(period.start && period.end)}
|
||||
>
|
||||
<>{configs?.footer?.apply ? configs.footer.apply : "Apply"}</>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useContext, useEffect, useRef } from "react";
|
||||
|
||||
import { BORDER_COLOR, DATE_FORMAT, RING_COLOR } from "../constants";
|
||||
import DatepickerContext from "../contexts/DatepickerContext";
|
||||
import { dateIsValid, parseFormattedDate } from "../helpers";
|
||||
|
||||
import ToggleButton from "./ToggleButton";
|
||||
|
||||
type Props = {
|
||||
setContextRef?: (ref: React.RefObject<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const Input: React.FC<Props> = (e: Props) => {
|
||||
// Context
|
||||
const {
|
||||
primaryColor,
|
||||
period,
|
||||
dayHover,
|
||||
changeDayHover,
|
||||
calendarContainer,
|
||||
arrowContainer,
|
||||
inputText,
|
||||
changeInputText,
|
||||
hideDatepicker,
|
||||
changeDatepickerValue,
|
||||
asSingle,
|
||||
placeholder,
|
||||
separator,
|
||||
disabled,
|
||||
inputClassName,
|
||||
toggleClassName,
|
||||
toggleIcon,
|
||||
readOnly,
|
||||
displayFormat,
|
||||
inputId,
|
||||
inputName,
|
||||
classNames,
|
||||
popoverDirection,
|
||||
} = useContext(DatepickerContext);
|
||||
|
||||
// UseRefs
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Functions
|
||||
const getClassName = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
|
||||
if (
|
||||
input &&
|
||||
typeof classNames !== "undefined" &&
|
||||
typeof classNames?.input === "function"
|
||||
) {
|
||||
return classNames.input(input);
|
||||
}
|
||||
|
||||
const border =
|
||||
BORDER_COLOR.focus[primaryColor as keyof typeof BORDER_COLOR.focus];
|
||||
const ring =
|
||||
RING_COLOR["second-focus"][
|
||||
primaryColor as keyof (typeof RING_COLOR)["second-focus"]
|
||||
];
|
||||
|
||||
const defaultInputClassName = `relative flex h-9 w-full rounded-md border border-gray-200 border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm ${border} ${ring}`;
|
||||
|
||||
return typeof inputClassName === "function"
|
||||
? inputClassName(defaultInputClassName)
|
||||
: typeof inputClassName === "string" && inputClassName !== ""
|
||||
? inputClassName
|
||||
: defaultInputClassName;
|
||||
}, [inputRef, classNames, primaryColor, inputClassName]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
const dates = [];
|
||||
if (asSingle) {
|
||||
const date = parseFormattedDate(inputValue, displayFormat);
|
||||
if (dateIsValid(date.toDate())) {
|
||||
dates.push(date.format(DATE_FORMAT));
|
||||
}
|
||||
} else {
|
||||
const parsed = inputValue.split(separator);
|
||||
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
if (parsed.length === 2) {
|
||||
startDate = parseFormattedDate(parsed[0], displayFormat);
|
||||
endDate = parseFormattedDate(parsed[1], displayFormat);
|
||||
} else {
|
||||
const middle = Math.floor(inputValue.length / 2);
|
||||
startDate = parseFormattedDate(
|
||||
inputValue.slice(0, middle),
|
||||
displayFormat
|
||||
);
|
||||
endDate = parseFormattedDate(inputValue.slice(middle), displayFormat);
|
||||
}
|
||||
|
||||
if (
|
||||
dateIsValid(startDate.toDate()) &&
|
||||
dateIsValid(endDate.toDate()) &&
|
||||
startDate.isBefore(endDate)
|
||||
) {
|
||||
dates.push(startDate.format(DATE_FORMAT));
|
||||
dates.push(endDate.format(DATE_FORMAT));
|
||||
}
|
||||
}
|
||||
|
||||
if (dates[0]) {
|
||||
changeDatepickerValue(
|
||||
{
|
||||
startDate: dates[0],
|
||||
endDate: dates[1] || dates[0],
|
||||
},
|
||||
e.target
|
||||
);
|
||||
if (dates[1])
|
||||
changeDayHover(dayjs(dates[1]).add(-1, "day").format(DATE_FORMAT));
|
||||
else changeDayHover(dates[0]);
|
||||
} else {
|
||||
changeDatepickerValue(null, e.target);
|
||||
}
|
||||
|
||||
changeInputText(e.target.value);
|
||||
},
|
||||
[
|
||||
asSingle,
|
||||
displayFormat,
|
||||
separator,
|
||||
changeDatepickerValue,
|
||||
changeDayHover,
|
||||
changeInputText,
|
||||
]
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
input.blur();
|
||||
}
|
||||
hideDatepicker();
|
||||
}
|
||||
},
|
||||
[hideDatepicker]
|
||||
);
|
||||
|
||||
const renderToggleIcon = useCallback(
|
||||
(isEmpty: boolean) => {
|
||||
return typeof toggleIcon === "undefined" ? (
|
||||
<ToggleButton isEmpty={isEmpty} />
|
||||
) : (
|
||||
toggleIcon(isEmpty)
|
||||
);
|
||||
},
|
||||
[toggleIcon]
|
||||
);
|
||||
|
||||
const getToggleClassName = useCallback(() => {
|
||||
const button = buttonRef.current;
|
||||
|
||||
if (
|
||||
button &&
|
||||
typeof classNames !== "undefined" &&
|
||||
typeof classNames?.toggleButton === "function"
|
||||
) {
|
||||
return classNames.toggleButton(button);
|
||||
}
|
||||
|
||||
const defaultToggleClassName =
|
||||
"absolute right-0 top-0 h-full px-3 text-gray-400 focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
|
||||
return typeof toggleClassName === "function"
|
||||
? toggleClassName(defaultToggleClassName)
|
||||
: typeof toggleClassName === "string" && toggleClassName !== ""
|
||||
? toggleClassName
|
||||
: defaultToggleClassName;
|
||||
}, [toggleClassName, buttonRef, classNames]);
|
||||
|
||||
// UseEffects && UseLayoutEffect
|
||||
useEffect(() => {
|
||||
if (inputRef && e.setContextRef && typeof e.setContextRef === "function") {
|
||||
e.setContextRef(inputRef);
|
||||
}
|
||||
}, [e, inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const button = buttonRef?.current;
|
||||
|
||||
function focusInput(e: Event) {
|
||||
e.stopPropagation();
|
||||
const input = inputRef.current;
|
||||
|
||||
if (input) {
|
||||
input.focus();
|
||||
if (inputText) {
|
||||
changeInputText("");
|
||||
if (dayHover) {
|
||||
changeDayHover(null);
|
||||
}
|
||||
if (period.start && period.end) {
|
||||
changeDatepickerValue(
|
||||
{
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
},
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.addEventListener("click", focusInput);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (button) {
|
||||
button.removeEventListener("click", focusInput);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
changeDatepickerValue,
|
||||
changeDayHover,
|
||||
changeInputText,
|
||||
dayHover,
|
||||
inputText,
|
||||
period.end,
|
||||
period.start,
|
||||
inputRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const div = calendarContainer?.current;
|
||||
const input = inputRef.current;
|
||||
const arrow = arrowContainer?.current;
|
||||
|
||||
function showCalendarContainer() {
|
||||
if (arrow && div && div.classList.contains("hidden")) {
|
||||
div.classList.remove("hidden");
|
||||
div.classList.add("block");
|
||||
|
||||
// window.innerWidth === 767
|
||||
const popoverOnUp = popoverDirection == "up";
|
||||
const popoverOnDown = popoverDirection === "down";
|
||||
if (
|
||||
popoverOnUp ||
|
||||
(window.innerWidth > 767 &&
|
||||
window.screen.height - 100 < div.getBoundingClientRect().bottom &&
|
||||
!popoverOnDown)
|
||||
) {
|
||||
div.classList.add("bottom-full");
|
||||
div.classList.add("mb-2.5");
|
||||
div.classList.remove("mt-2.5");
|
||||
arrow.classList.add("-bottom-2");
|
||||
arrow.classList.add("border-r");
|
||||
arrow.classList.add("border-b");
|
||||
arrow.classList.remove("border-l");
|
||||
arrow.classList.remove("border-t");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
div.classList.remove("translate-y-4");
|
||||
div.classList.remove("opacity-0");
|
||||
div.classList.add("translate-y-0");
|
||||
div.classList.add("opacity-1");
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (div && input) {
|
||||
input.addEventListener("focus", showCalendarContainer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (input) {
|
||||
input.removeEventListener("focus", showCalendarContainer);
|
||||
}
|
||||
};
|
||||
}, [calendarContainer, arrowContainer, popoverDirection]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{disabled ? (
|
||||
<div
|
||||
className={
|
||||
"flex h-9 w-full rounded-md border-input bg-gray-100 items-center px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
}
|
||||
>
|
||||
{inputText ? inputText : "-"}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={getClassName()}
|
||||
readOnly={readOnly}
|
||||
placeholder={
|
||||
placeholder
|
||||
? placeholder
|
||||
: `${displayFormat}${
|
||||
asSingle ? "" : ` ${separator} ${displayFormat}`
|
||||
}`
|
||||
}
|
||||
value={inputText}
|
||||
id={inputId}
|
||||
name={inputName}
|
||||
autoComplete="off"
|
||||
role="presentation"
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
disabled={disabled}
|
||||
className={getToggleClassName()}
|
||||
>
|
||||
{renderToggleIcon(inputText == null || !inputText?.length)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useContext, useMemo } from "react";
|
||||
|
||||
import { DATE_FORMAT, TEXT_COLOR } from "../constants";
|
||||
import DEFAULT_SHORTCUTS from "../constants/shortcuts";
|
||||
import DatepickerContext from "../contexts/DatepickerContext";
|
||||
import { Period, ShortcutsItem } from "../types";
|
||||
|
||||
interface ItemTemplateProps {
|
||||
children: any;
|
||||
key: number;
|
||||
item: ShortcutsItem | ShortcutsItem[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const ItemTemplate = React.memo((props: ItemTemplateProps) => {
|
||||
const {
|
||||
primaryColor,
|
||||
period,
|
||||
changePeriod,
|
||||
updateFirstDate,
|
||||
dayHover,
|
||||
changeDayHover,
|
||||
hideDatepicker,
|
||||
changeDatepickerValue,
|
||||
} = useContext(DatepickerContext);
|
||||
|
||||
// Functions
|
||||
const getClassName: () => string = useCallback(() => {
|
||||
const textColor =
|
||||
TEXT_COLOR["600"][primaryColor as keyof (typeof TEXT_COLOR)["600"]];
|
||||
const textColorHover =
|
||||
TEXT_COLOR.hover[primaryColor as keyof typeof TEXT_COLOR.hover];
|
||||
return `whitespace-nowrap w-1/2 md:w-1/3 lg:w-auto transition-all duration-300 hover:bg-gray-100 dark:hover:bg-white/10 p-2 rounded cursor-pointer ${textColor} ${textColorHover}`;
|
||||
}, [primaryColor]);
|
||||
|
||||
const chosePeriod = useCallback(
|
||||
(item: Period) => {
|
||||
if (dayHover) {
|
||||
changeDayHover(null);
|
||||
}
|
||||
if (period.start || period.end) {
|
||||
changePeriod({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
}
|
||||
changePeriod(item);
|
||||
changeDatepickerValue({
|
||||
startDate: item.start,
|
||||
endDate: item.end,
|
||||
});
|
||||
updateFirstDate(dayjs(item.start));
|
||||
hideDatepicker();
|
||||
},
|
||||
[
|
||||
changeDatepickerValue,
|
||||
changeDayHover,
|
||||
changePeriod,
|
||||
dayHover,
|
||||
hideDatepicker,
|
||||
period.end,
|
||||
period.start,
|
||||
updateFirstDate,
|
||||
]
|
||||
);
|
||||
|
||||
const children = props?.children;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={getClassName()}
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
chosePeriod(props?.item.period);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const Shortcuts: React.FC = () => {
|
||||
// Contexts
|
||||
const { configs } = useContext(DatepickerContext);
|
||||
|
||||
const callPastFunction = useCallback((data: unknown, numberValue: number) => {
|
||||
return typeof data === "function" ? data(numberValue) : null;
|
||||
}, []);
|
||||
|
||||
const shortcutOptions = useMemo<
|
||||
[string, ShortcutsItem | ShortcutsItem[]][]
|
||||
>(() => {
|
||||
if (!configs?.shortcuts) {
|
||||
return Object.entries(DEFAULT_SHORTCUTS);
|
||||
}
|
||||
|
||||
return Object.entries(configs.shortcuts).flatMap(([key, customConfig]) => {
|
||||
if (Object.prototype.hasOwnProperty.call(DEFAULT_SHORTCUTS, key)) {
|
||||
return [[key, DEFAULT_SHORTCUTS[key]]];
|
||||
}
|
||||
|
||||
const { text, period } = customConfig as {
|
||||
text: string;
|
||||
period: { start: string; end: string };
|
||||
};
|
||||
if (!text || !period) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = dayjs(period.start);
|
||||
const end = dayjs(period.end);
|
||||
|
||||
if (
|
||||
start.isValid() &&
|
||||
end.isValid() &&
|
||||
(start.isBefore(end) || start.isSame(end))
|
||||
) {
|
||||
return [
|
||||
[
|
||||
text,
|
||||
{
|
||||
text,
|
||||
period: {
|
||||
start: start.format(DATE_FORMAT),
|
||||
end: end.format(DATE_FORMAT),
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}, [configs]);
|
||||
|
||||
const printItemText = useCallback((item: ShortcutsItem) => {
|
||||
return item?.text ?? null;
|
||||
}, []);
|
||||
|
||||
return shortcutOptions?.length ? (
|
||||
<div className="md:border-b mb-3 lg:mb-0 lg:border-r lg:border-b-0 border-gray-300 dark:border-gray-700 pr-1">
|
||||
<ul className="w-full tracking-wide flex flex-wrap lg:flex-col pb-1 lg:pb-0">
|
||||
{shortcutOptions.map(([key, item], index: number) =>
|
||||
Array.isArray(item) ? (
|
||||
item.map((item, index) => (
|
||||
<ItemTemplate key={index} item={item}>
|
||||
<>
|
||||
{key === "past" &&
|
||||
configs?.shortcuts &&
|
||||
key in configs.shortcuts &&
|
||||
item.daysNumber
|
||||
? callPastFunction(
|
||||
configs.shortcuts[key as "past"],
|
||||
item.daysNumber
|
||||
)
|
||||
: item.text}
|
||||
</>
|
||||
</ItemTemplate>
|
||||
))
|
||||
) : (
|
||||
<ItemTemplate key={index} item={item}>
|
||||
{configs?.shortcuts && key in configs.shortcuts
|
||||
? typeof configs.shortcuts[
|
||||
key as keyof typeof configs.shortcuts
|
||||
] === "object"
|
||||
? printItemText(item)
|
||||
: configs.shortcuts[key as keyof typeof configs.shortcuts]
|
||||
: printItemText(item)}
|
||||
</ItemTemplate>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default Shortcuts;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue