ignore folder
This commit is contained in:
4
components/ui/Alert.tsx
Normal file
4
components/ui/Alert.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
export default function Alert({ message, type = "info" }: { message: string; type?: "info" | "success" | "danger" }) {
|
||||
const variant = type === "danger" ? "danger" : type === "success" ? "success" : "info";
|
||||
return <div className={`alert alert-${variant}`}>{message}</div>;
|
||||
}
|
||||
19
components/ui/Badge.tsx
Normal file
19
components/ui/Badge.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
export default function Badge({
|
||||
variant,
|
||||
children
|
||||
}: {
|
||||
variant: "success" | "danger" | "warning" | "secondary" | "info";
|
||||
children: string;
|
||||
}) {
|
||||
const cls =
|
||||
variant === "success"
|
||||
? "bg-success"
|
||||
: variant === "danger"
|
||||
? "bg-danger"
|
||||
: variant === "warning"
|
||||
? "bg-warning"
|
||||
: variant === "info"
|
||||
? "bg-info"
|
||||
: "bg-secondary";
|
||||
return <span className={`badge ${cls}`}>{children}</span>;
|
||||
}
|
||||
31
components/ui/Breadcrumb.tsx
Normal file
31
components/ui/Breadcrumb.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type BreadcrumbItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export default function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="breadcrumb">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={item.label + index}
|
||||
className={`breadcrumb-item ${index === items.length - 1 ? "active" : ""}`}
|
||||
>
|
||||
{index === items.length - 1 || !item.href ? (
|
||||
<span>{item.label}</span>
|
||||
) : (
|
||||
<Link href={item.href} className="text-decoration-none">
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
14
components/ui/Card.tsx
Normal file
14
components/ui/Card.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export default function Card({
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card page-card">
|
||||
<div className="card-header fw-bold">{title}</div>
|
||||
<div className="card-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
components/ui/ConfirmDialog.tsx
Normal file
41
components/ui/ConfirmDialog.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import Dialog from "./Dialog";
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
loading
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<button className="btn btn-outline-secondary" onClick={onCancel} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={onConfirm} disabled={loading}>
|
||||
{loading ? "Working..." : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>{message}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
39
components/ui/DateRange.tsx
Normal file
39
components/ui/DateRange.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
export type DateRangeValue = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export default function DateRange({
|
||||
from,
|
||||
to,
|
||||
onFromChange,
|
||||
onToChange
|
||||
}: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
onFromChange: (value: string) => void;
|
||||
onToChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="d-flex gap-2">
|
||||
<label className="flex-fill">
|
||||
<span className="form-label">From</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control"
|
||||
value={from ?? ""}
|
||||
onChange={(event) => onFromChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-fill">
|
||||
<span className="form-label">To</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control"
|
||||
value={to ?? ""}
|
||||
onChange={(event) => onToChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/ui/Dialog.tsx
Normal file
60
components/ui/Dialog.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type DialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
size?: "sm" | "lg" | "xl";
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
const sizeClass = {
|
||||
sm: "modal-dialog modal-sm",
|
||||
lg: "modal-dialog modal-lg",
|
||||
xl: "modal-dialog modal-xl"
|
||||
};
|
||||
|
||||
export default function Dialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
onClose,
|
||||
footer,
|
||||
size = "sm",
|
||||
loading
|
||||
}: DialogProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onEsc);
|
||||
return () => window.removeEventListener("keydown", onEsc);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="modal d-block" style={{ background: "rgba(0,0,0,.4)" }}>
|
||||
<div className={sizeClass[size]} style={{ marginTop: "6rem" }}>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button className="btn-close" onClick={onClose} aria-label="close" />
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{description && <p className="text-muted mb-3">{description}</p>}
|
||||
{loading ? <div className="text-center text-muted">Loading...</div> : children}
|
||||
</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
33
components/ui/Drawer.tsx
Normal file
33
components/ui/Drawer.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export default function Drawer({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="modal d-block" style={{ background: "rgba(0,0,0,.35)" }} role="dialog">
|
||||
<div className="modal-dialog modal-dialog-end">
|
||||
<div className="modal-content h-100">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button className="btn-close" onClick={onClose} aria-label="close" />
|
||||
</div>
|
||||
<div className="modal-body p-0">
|
||||
<div className="p-3">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
18
components/ui/EmptyState.tsx
Normal file
18
components/ui/EmptyState.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function EmptyState({
|
||||
title,
|
||||
description,
|
||||
cta
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
cta?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-center py-5">
|
||||
<div className="display-6 text-muted mb-2">🎯</div>
|
||||
<h4>{title}</h4>
|
||||
{description && <p className="text-muted">{description}</p>}
|
||||
{cta}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
components/ui/FileUpload.tsx
Normal file
49
components/ui/FileUpload.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { ChangeEvent, useRef } from "react";
|
||||
|
||||
export type FileUploadProps = {
|
||||
label: string;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxBytes?: number;
|
||||
onFileSelect: (files: File[]) => void;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
resetToken?: number;
|
||||
};
|
||||
|
||||
export default function FileUpload({
|
||||
label,
|
||||
accept,
|
||||
multiple,
|
||||
maxBytes,
|
||||
onFileSelect,
|
||||
disabled,
|
||||
helperText,
|
||||
resetToken
|
||||
}: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const targetFiles = Array.from(event.target.files ?? []);
|
||||
const files = maxBytes ? targetFiles.filter((file) => file.size <= maxBytes) : targetFiles;
|
||||
onFileSelect(files);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="d-block">
|
||||
<span className="form-label">{label}</span>
|
||||
<input
|
||||
key={resetToken}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
className="form-control"
|
||||
onChange={onChange}
|
||||
/>
|
||||
{helperText && <small className="text-muted">{helperText}</small>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
23
components/ui/FormField.tsx
Normal file
23
components/ui/FormField.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
children?: ReactNode;
|
||||
type?: "text" | "password" | "checkbox" | "textarea" | "select";
|
||||
value?: string | boolean;
|
||||
onValueChange?: (value: string) => void;
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
export default function FormField({ label, children, type = "text", ...props }: Props) {
|
||||
return (
|
||||
<label className="form-label">
|
||||
{label}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
25
components/ui/Modal.tsx
Normal file
25
components/ui/Modal.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type ModalProps = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function Modal({ isOpen, title, children, onClose }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="modal d-block" style={{ background: "rgba(0,0,0,0.4)" }} role="dialog">
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button className="btn-close" onClick={onClose} aria-label="close" />
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
components/ui/PageHeader.tsx
Normal file
27
components/ui/PageHeader.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import Breadcrumb, { BreadcrumbItem } from "./Breadcrumb";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
breadcrumb
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
breadcrumb?: BreadcrumbItem[];
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
{breadcrumb && <Breadcrumb items={breadcrumb} />}
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h2 className="mb-1">{title}</h2>
|
||||
{description && <p className="text-muted mb-0">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
components/ui/Pagination.tsx
Normal file
29
components/ui/Pagination.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export default function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center gap-1">
|
||||
<button className="btn btn-outline-secondary btn-sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
|
||||
Prev
|
||||
</button>
|
||||
<span className="px-2 d-flex align-items-center">
|
||||
Page {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
components/ui/Select.tsx
Normal file
38
components/ui/Select.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { ChangeEvent } from "react";
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function Select({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}: {
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="form-label">
|
||||
{label}
|
||||
<select
|
||||
className="form-select"
|
||||
value={value}
|
||||
onChange={(event: ChangeEvent<HTMLSelectElement>) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
8
components/ui/Spinner.tsx
Normal file
8
components/ui/Spinner.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
export default function Spinner({ label = "Loading..." }: { label?: string }) {
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status" />
|
||||
<span className="text-muted">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
components/ui/Table.tsx
Normal file
52
components/ui/Table.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export type TableColumn<T extends Record<string, unknown>> = {
|
||||
key: keyof T | "actions" | string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
type TableProps<T extends Record<string, unknown>> = {
|
||||
columns: TableColumn<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
noDataText?: string;
|
||||
};
|
||||
|
||||
export default function DataTable<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
noDataText = "No data"
|
||||
}: TableProps<T>) {
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
{loading && (
|
||||
<div className="py-4 text-center text-muted">Loading ...</div>
|
||||
)}
|
||||
{!loading && data.length === 0 && <div className="py-4 text-center text-muted">{noDataText}</div>}
|
||||
{!loading && data.length > 0 && (
|
||||
<table className="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={String(col.key)}>{col.header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, idx) => (
|
||||
<tr key={String((row as { id?: string })?.id ?? idx)}>
|
||||
{columns.map((col) => (
|
||||
<td key={String(col.key)} className="align-middle">
|
||||
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key as keyof T] ?? "-")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
components/ui/Tabs.tsx
Normal file
35
components/ui/Tabs.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type TabItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type TabsProps = {
|
||||
items: TabItem[];
|
||||
active: string;
|
||||
onChange: (key: string) => void;
|
||||
};
|
||||
|
||||
export default function Tabs({ items, active, onChange }: TabsProps) {
|
||||
return (
|
||||
<div>
|
||||
<ul className="nav nav-tabs mb-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.key} className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${item.key === active ? "active" : ""}`}
|
||||
onClick={() => onChange(item.key)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
{items.find((item) => item.key === active)?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user