mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
Merge branch 'alpha' into feat/telebirr-integration
This commit is contained in:
@@ -3,7 +3,9 @@ import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
StreamableFile,
|
||||
} from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
import { Observable } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
|
||||
@@ -16,18 +18,28 @@ export interface StandardResponse<T> {
|
||||
@Injectable()
|
||||
export class ResponseTransformInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
StandardResponse<T>
|
||||
StandardResponse<T> | T
|
||||
> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler<T>,
|
||||
): Observable<StandardResponse<T>> {
|
||||
): Observable<StandardResponse<T> | T> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
map((data) => {
|
||||
if (
|
||||
data instanceof StreamableFile ||
|
||||
data instanceof Buffer ||
|
||||
data instanceof Readable
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export { CbeBirrProvider } from './providers/cbe-birr/cbe-birr.provider';
|
||||
export { EBirrProvider } from './providers/ebirr/ebirr.provider';
|
||||
export { CardProvider } from './providers/card/card.provider';
|
||||
export { WaafiProvider } from './providers/waafi/waafi.provider';
|
||||
export { DMoneyProvider } from './providers/dmoney/dmoney.provider';
|
||||
|
||||
// Telebirr crypto + types (exported for apps that build/verify signatures directly)
|
||||
export {
|
||||
@@ -61,6 +62,7 @@ export type {
|
||||
WaafiWebhookEvent,
|
||||
WaafiWebhookStatus,
|
||||
} from './webhooks/waafi-webhook.types';
|
||||
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
|
||||
|
||||
// DI token for injecting all providers as an array (future multi-provider wiring)
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
interface DMoneyAuthResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface DMoneyInitiateRequest {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
notifyUrl: string;
|
||||
payerPhone?: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface DMoneyInitiateResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
checkoutUrl?: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface DMoneyQueryResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
payerPhone?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DMoneyProvider implements PaymentProvider {
|
||||
readonly method = ProviderMethod.DMONEY;
|
||||
private readonly logger = new Logger(DMoneyProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) { }
|
||||
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const token = await this.getFabricToken();
|
||||
const amount = (input.amountMinor / 100).toFixed(2);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const requestBody: DMoneyInitiateRequest = {
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
returnUrl: this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
timestamp,
|
||||
signature: this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<DMoneyInitiateResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/initiate`,
|
||||
requestBody,
|
||||
token,
|
||||
);
|
||||
|
||||
if (!response.success || !response.orderId) {
|
||||
throw new Error(`DMoney initiate failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
|
||||
|
||||
return {
|
||||
providerOrderId: response.orderId,
|
||||
clientAction: response.checkoutUrl
|
||||
? { type: "REDIRECT", url: response.checkoutUrl }
|
||||
: {
|
||||
type: "REDIRECT",
|
||||
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
||||
},
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const token = await this.getFabricToken();
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const response = await this.postJson<DMoneyQueryResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/query`,
|
||||
{
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
signature,
|
||||
},
|
||||
token,
|
||||
);
|
||||
|
||||
const mapped = this.mapStatus(response.status);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transactionId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { signature, ...data } = payload;
|
||||
if (!signature || typeof signature !== "string") return false;
|
||||
|
||||
const expectedSignature = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): ProviderPaymentStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case "SUCCESS":
|
||||
case "COMPLETED":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case "FAILED":
|
||||
case "REJECTED":
|
||||
case "EXPIRED":
|
||||
case "CANCELLED":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case "PENDING":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case "PROCESSING":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private async getFabricToken(): Promise<string> {
|
||||
const response = await this.postJson<DMoneyAuthResponse>(
|
||||
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/token`,
|
||||
{
|
||||
appSecret: this.appSecret,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.token) {
|
||||
throw new Error(
|
||||
`DMoney authentication failed: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.token;
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&");
|
||||
|
||||
return crypto
|
||||
.createHmac("sha256", this.secretKey)
|
||||
.update(signString)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
token?: string,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
headers,
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(
|
||||
`DMoney POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`DMoney POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: DMoneyInitiateRequest): Record<string, unknown> {
|
||||
const { signature: _signature, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>("dmoney.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantId(): string {
|
||||
return this.config.get<string>("dmoney.merchantId") ?? "";
|
||||
}
|
||||
private get appSecret(): string {
|
||||
return this.config.get<string>("dmoney.appSecret") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>("dmoney.secretKey") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>("dmoney.notifyUrl") ?? "";
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,11 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
const clientAction =
|
||||
platform === 'mobile'
|
||||
? {
|
||||
type: 'LAUNCH_APP' as const,
|
||||
appId: this.merchantAppId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
type: 'LAUNCH_APP' as const,
|
||||
appId: this.merchantAppId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
|
||||
|
||||
return {
|
||||
@@ -195,6 +195,7 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
redirect_url: input.redirectUrl
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
|
||||
|
||||
@@ -67,3 +67,4 @@ export interface QueryOrderResponse {
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface DMoneyWebhookPayload {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
payerPhone?: string;
|
||||
signature: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export enum ProviderMethod {
|
||||
EBIRR = "EBIRR",
|
||||
WAAFI = "WAAFI",
|
||||
CARD = "CARD",
|
||||
DMONEY = "DMONEY",
|
||||
}
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
@@ -48,6 +49,9 @@ export interface ProviderInitiationInput {
|
||||
* MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page.
|
||||
*/
|
||||
payerAccount?: string;
|
||||
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */
|
||||
returnUrl?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationResult {
|
||||
|
||||
72
packages/types/src/freight/dropdown_settings.ts
Normal file
72
packages/types/src/freight/dropdown_settings.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export interface IDropdownOptionMeta {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface IDropdownOption extends BaseEntity {
|
||||
settingId: string;
|
||||
/** Stored value (machine-readable identifier). */
|
||||
value: string;
|
||||
/** Display label shown to end users. */
|
||||
label: string;
|
||||
/** Optional helper text. */
|
||||
note?: string | null;
|
||||
disabled: boolean;
|
||||
order: number;
|
||||
meta?: IDropdownOptionMeta | null;
|
||||
}
|
||||
|
||||
export interface IDropdownSettingMeta {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
searchable?: boolean;
|
||||
clearable?: boolean;
|
||||
permissions?: string[];
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface IDropdownSetting extends BaseEntity {
|
||||
/** Stable code referenced from forms (snake_case). */
|
||||
code: string;
|
||||
/** Display label for admins. */
|
||||
label: string;
|
||||
description?: string | null;
|
||||
multiple: boolean;
|
||||
meta?: IDropdownSettingMeta | null;
|
||||
/**
|
||||
* Options that belong to this dropdown. Named `children` to match the shape
|
||||
* historically consumed by the freight portal.
|
||||
*/
|
||||
children: IDropdownOption[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Wire DTOs (shared between API and frontend)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CreateDropdownOptionDto {
|
||||
value: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
disabled?: boolean;
|
||||
order?: number;
|
||||
meta?: IDropdownOptionMeta;
|
||||
}
|
||||
|
||||
export type UpdateDropdownOptionDto = Partial<CreateDropdownOptionDto>;
|
||||
|
||||
export interface CreateDropdownSettingDto {
|
||||
code: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
multiple?: boolean;
|
||||
meta?: IDropdownSettingMeta;
|
||||
children?: CreateDropdownOptionDto[];
|
||||
}
|
||||
|
||||
export type UpdateDropdownSettingDto = Partial<
|
||||
Omit<CreateDropdownSettingDto, "children">
|
||||
>;
|
||||
89
packages/types/src/freight/file_upload_settings.ts
Normal file
89
packages/types/src/freight/file_upload_settings.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export type FileUploadEntity =
|
||||
| "customer"
|
||||
| "booking"
|
||||
| "consignment"
|
||||
| "shipment"
|
||||
| "invoice"
|
||||
| "train"
|
||||
| "other";
|
||||
|
||||
export interface IFileUploadField extends BaseEntity {
|
||||
settingId: string;
|
||||
/** Stable identifier used by the API / object storage. */
|
||||
fileKey: string;
|
||||
/** Human-readable label rendered above the input. */
|
||||
fileLabel: string;
|
||||
helpText?: string | null;
|
||||
isRequired: boolean;
|
||||
isMultiple: boolean;
|
||||
/** Upper bound on file count when isMultiple is true. */
|
||||
maxFiles: number;
|
||||
allowedExtensions: string[];
|
||||
maxSizeMb: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface IFileUploadSetting extends BaseEntity {
|
||||
/** Stable code referenced from forms (snake_case). */
|
||||
code: string;
|
||||
/** Display label for admins. */
|
||||
label: string;
|
||||
description?: string | null;
|
||||
entity: FileUploadEntity;
|
||||
fields: IFileUploadField[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Wire DTOs (shared between API and frontend)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export interface CreateFileUploadFieldDto {
|
||||
fileKey: string;
|
||||
fileLabel: string;
|
||||
helpText?: string;
|
||||
isRequired: boolean;
|
||||
isMultiple: boolean;
|
||||
maxFiles: number;
|
||||
allowedExtensions: string[];
|
||||
maxSizeMb: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type UpdateFileUploadFieldDto = Partial<CreateFileUploadFieldDto>;
|
||||
|
||||
export interface CreateFileUploadSettingDto {
|
||||
code: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
entity: FileUploadEntity;
|
||||
fields?: CreateFileUploadFieldDto[];
|
||||
}
|
||||
|
||||
export type UpdateFileUploadSettingDto = Partial<
|
||||
Omit<CreateFileUploadSettingDto, "fields">
|
||||
>;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Helpers — encode the required × multiple matrix.
|
||||
*
|
||||
* required | multiple | min | max
|
||||
* ---------|----------|------|--------------
|
||||
* no | no | 0 | 1
|
||||
* yes | no | 1 | 1
|
||||
* no | yes | 0 | field.maxFiles
|
||||
* yes | yes | 1 | field.maxFiles
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function getMinFiles(
|
||||
field: Pick<IFileUploadField, "isRequired">,
|
||||
): number {
|
||||
return field.isRequired ? 1 : 0;
|
||||
}
|
||||
|
||||
export function getEffectiveMaxFiles(
|
||||
field: Pick<IFileUploadField, "isMultiple" | "maxFiles">,
|
||||
): number {
|
||||
return field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
}
|
||||
@@ -1,11 +1,57 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export * from "./file_upload_settings";
|
||||
export * from "./dropdown_settings";
|
||||
|
||||
export enum TradeDirection {
|
||||
IMPORT = 'IMPORT',
|
||||
EXPORT = 'EXPORT',
|
||||
BOTH = 'BOTH',
|
||||
}
|
||||
|
||||
export enum PriorityType {
|
||||
USD_PAYER = 'USD_PAYER',
|
||||
RAIL_AND_FORWARDING = 'RAIL_AND_FORWARDING',
|
||||
GOVERNMENT_ACCOUNT = 'GOVERNMENT_ACCOUNT',
|
||||
HIGH_VOLUME_SHIPMENT = 'HIGH_VOLUME_SHIPMENT',
|
||||
}
|
||||
|
||||
export enum ExceededAction {
|
||||
WARNING_ONLY = 'WARNING_ONLY',
|
||||
HARD_BLOCK = 'HARD_BLOCK',
|
||||
}
|
||||
|
||||
export enum CalculationMethod {
|
||||
PER_TON = 'PER_TON',
|
||||
FLAT_FEE = 'FLAT_FEE',
|
||||
PERCENTAGE = 'PERCENTAGE',
|
||||
}
|
||||
|
||||
export enum FreightType {
|
||||
Container = 'CONTAINER',
|
||||
Bulk = 'BULK',
|
||||
}
|
||||
|
||||
export enum BookingStatus {
|
||||
Draft = "DRAFT",
|
||||
Confirmed = "CONFIRMED",
|
||||
Submitted = "SUBMITTED",
|
||||
ChangesRequested = "CHANGES_REQUESTED",
|
||||
PendingApproval = "PENDING_APPROVAL",
|
||||
ApprovedPendingSignature = "APPROVED_PENDING_SIGNATURE",
|
||||
Approved = "APPROVED",
|
||||
ContractReady = "CONTRACT_READY",
|
||||
SignedCustomer = "SIGNED_CUSTOMER",
|
||||
FullyExecuted = "FULLY_EXECUTED",
|
||||
PnrGenerated = "PNR_GENERATED",
|
||||
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
Paid = "PAID",
|
||||
InTransit = "IN_TRANSIT",
|
||||
Completed = "COMPLETED",
|
||||
Delivered = "DELIVERED",
|
||||
Rejected = "REJECTED",
|
||||
Cancelled = "CANCELLED",
|
||||
PendingConsolidation = "PENDING_CONSOLIDATION",
|
||||
Consolidated = "CONSOLIDATED",
|
||||
}
|
||||
|
||||
export enum ConsignmentStatus {
|
||||
@@ -42,14 +88,65 @@ export enum PaymentStatus {
|
||||
Refunded = "REFUNDED",
|
||||
}
|
||||
|
||||
export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||
|
||||
export interface ICustomer extends BaseEntity {
|
||||
name: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address?: string;
|
||||
taxId?: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber?: string | null;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string | null;
|
||||
poaPhone?: string | null;
|
||||
poaAddress?: string | null;
|
||||
poaEmail?: string | null;
|
||||
poaLocation?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCustomerDto {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber: string;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaAddress?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type UpdateCustomerDto = Partial<CreateCustomerDto>;
|
||||
|
||||
export interface ITrain extends BaseEntity {
|
||||
code: string;
|
||||
capacityTons: number;
|
||||
@@ -75,14 +172,93 @@ export interface IConsignment extends BaseEntity {
|
||||
destinationStation: string;
|
||||
}
|
||||
|
||||
export interface IYard extends BaseEntity {
|
||||
code: string;
|
||||
label: string;
|
||||
country: string;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface IBooking extends BaseEntity {
|
||||
reference: string;
|
||||
customerId: string;
|
||||
trainId?: string;
|
||||
trainId?: string | null;
|
||||
status: BookingStatus;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
paymentStatus: PaymentStatus;
|
||||
|
||||
contractType: "NEW" | "RENEWAL";
|
||||
previousContractId?: string | null;
|
||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
||||
|
||||
firstMileEnabled: boolean;
|
||||
firstMilePickupAddress?: string | null;
|
||||
lastMileEnabled: boolean;
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||
originYard?: IYard | null;
|
||||
destinationYard?: IYard | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
|
||||
freightType: FreightType;
|
||||
freightSubtype?: string | null;
|
||||
|
||||
isHazardous: boolean;
|
||||
isRefrigerated: boolean;
|
||||
|
||||
tradeDirection: "IMPORT" | "EXPORT";
|
||||
paymentCurrency: string;
|
||||
allowConsolidation: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
financialTerms?: string | null;
|
||||
|
||||
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||
|
||||
versionNumber: number;
|
||||
priorityScore: number;
|
||||
|
||||
approvedByStaffId?: string | null;
|
||||
approvedByStaffAt?: string | null;
|
||||
signedByDirectorId?: string | null;
|
||||
signedByDirectorAt?: string | null;
|
||||
signedByCeoId?: string | null;
|
||||
signedByCeoAt?: string | null;
|
||||
|
||||
files?: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
resourceId: string;
|
||||
resource: string;
|
||||
signedUrl?: string | null;
|
||||
}>;
|
||||
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
pricingBreakdown?: PricingBreakdown | null;
|
||||
}
|
||||
|
||||
export interface PricingBreakdownLineItem {
|
||||
code: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PricingBreakdown {
|
||||
currency: string;
|
||||
lineItems: PricingBreakdownLineItem[];
|
||||
generatedAt: string;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface IInvoice extends BaseEntity {
|
||||
@@ -94,3 +270,97 @@ export interface IInvoice extends BaseEntity {
|
||||
issuedAt: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
// ── Reference Data (booking form catalog) ──────────────────────────────────────
|
||||
|
||||
export interface BookingReferenceYard {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface BookingReferenceContainerType {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer: boolean;
|
||||
wagons_per_unit: number;
|
||||
}
|
||||
|
||||
export interface BookingReferenceContainerSizeGroup {
|
||||
size: string;
|
||||
types: BookingReferenceContainerType[];
|
||||
}
|
||||
|
||||
export interface BookingReferenceService {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface BookingReferenceShippingLine {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface BookingReferenceCargoTypeChild {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
show_free_text_box: boolean;
|
||||
}
|
||||
|
||||
export interface BookingReferenceCargoTypeGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
children?: BookingReferenceCargoTypeChild[];
|
||||
}
|
||||
|
||||
export interface BookingReferenceData {
|
||||
yard: BookingReferenceYard[];
|
||||
containers: BookingReferenceContainerSizeGroup[];
|
||||
service: BookingReferenceService[];
|
||||
shipping_line: BookingReferenceShippingLine[];
|
||||
cargo_type: BookingReferenceCargoTypeGroup[];
|
||||
}
|
||||
|
||||
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateBookingContainerDto {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
}
|
||||
|
||||
export interface CreateBookingDto {
|
||||
reference?: string;
|
||||
customerId?: string;
|
||||
companyId?: string;
|
||||
trainId?: string;
|
||||
scheduledDate: string;
|
||||
contractType: "NEW" | "RENEWAL";
|
||||
previousContractId?: string;
|
||||
serviceTypeId: string;
|
||||
firstMilePickupAddress?: string;
|
||||
lastMileDeliveryAddress?: string;
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
freightType: FreightType;
|
||||
cargoTypeId?: string;
|
||||
cargoFreeText?: string;
|
||||
shippingLineId?: string;
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous?: boolean;
|
||||
paymentCurrency: "ETB" | "USD";
|
||||
pnrCode?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
financialTerms?: string;
|
||||
containers?: CreateBookingContainerDto[];
|
||||
allowConsolidation?: boolean;
|
||||
}
|
||||
|
||||
20
packages/ui-common/components.json
Normal file
20
packages/ui-common/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "#components",
|
||||
"ui": "#components",
|
||||
"lib": "#lib",
|
||||
"utils": "#lib/utils",
|
||||
"hooks": "#hooks"
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./styles.css": "./dist/index.css",
|
||||
"./theme.css": "./src/styles/theme.css"
|
||||
},
|
||||
"scripts": {
|
||||
"build:styles": "tailwindcss -i ./src/styles/index.css -o ./dist/index.css",
|
||||
"check-types": "tsc --noEmit",
|
||||
"dev:styles": "tailwindcss -i ./src/styles/index.css -o ./dist/index.css --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
@@ -18,15 +23,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
"clsx": "^2.1.1"
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"postcss": "^8.4.47",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"imports": {
|
||||
"#components": "./src/components",
|
||||
"#components/*": "./src/components/*",
|
||||
"#lib": "./src/lib",
|
||||
"#lib/*": "./src/lib/*",
|
||||
"#hooks": "./src/hooks",
|
||||
"#hooks/*": "./src/hooks/*"
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/ui-common/postcss.config.js
Normal file
6
packages/ui-common/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Optional PostCSS configuration for applications that need it
|
||||
export const postcssConfig = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-300",
|
||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 disabled:bg-gray-50",
|
||||
ghost: "bg-transparent text-gray-900 hover:bg-gray-100",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
isLoading,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors disabled:cursor-not-allowed",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{isLoading ? "Loading..." : children}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./Button";
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button";
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Sidebar, { SidebarItem } from "./Sidebar";
|
||||
|
||||
export interface DashboardLayoutProps {
|
||||
@@ -7,32 +16,211 @@ export interface DashboardLayoutProps {
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
headerRight?: ReactNode;
|
||||
enableThemeToggle?: boolean;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
userInitials?: string;
|
||||
onLogout?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
const iconButtonClass =
|
||||
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-border bg-card text-foreground transition hover:border-[#10B981]/30 hover:bg-accent hover:text-accent-foreground";
|
||||
|
||||
const DashboardLayout = ({
|
||||
title,
|
||||
sidebarItems,
|
||||
activeHref,
|
||||
onNavigate,
|
||||
headerRight,
|
||||
enableThemeToggle = false,
|
||||
userName = "User",
|
||||
userEmail,
|
||||
userInitials,
|
||||
onLogout,
|
||||
children,
|
||||
}: DashboardLayoutProps) => (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar
|
||||
title={title}
|
||||
items={sidebarItems}
|
||||
activeHref={activeHref}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||
<div>{headerRight}</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
}: DashboardLayoutProps) => {
|
||||
const initials =
|
||||
userInitials ??
|
||||
userName
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0].toUpperCase())
|
||||
.join("");
|
||||
const [theme, setTheme] = useState<Theme>(() =>
|
||||
enableThemeToggle ? getInitialTheme() : "light",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableThemeToggle) return;
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}, [theme, enableThemeToggle]);
|
||||
|
||||
const toggleTheme = () =>
|
||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (
|
||||
userMenuRef.current &&
|
||||
!userMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setIsUserMenuOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
const themeToggleButton = enableThemeToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={
|
||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar
|
||||
title={title}
|
||||
items={sidebarItems}
|
||||
activeHref={activeHref}
|
||||
onNavigate={onNavigate}
|
||||
headerExtra={themeToggleButton}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border bg-background px-6 text-foreground">
|
||||
<div className="text-base font-medium">{title}</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Change language"
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={`${iconButtonClass} relative`}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-slate-900" />
|
||||
</button>
|
||||
|
||||
<div ref={userMenuRef} className="relative ml-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-accent aria-expanded:border-[#10B981]/30 aria-expanded:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#10B981] text-xs font-semibold text-white">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="hidden text-sm font-medium text-foreground md:block">
|
||||
{userName}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-muted-foreground transition ${isUserMenuOpen ? "rotate-180 text-[#10B981]" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-border bg-card py-1 shadow-lg"
|
||||
>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="text-sm font-semibold text-card-foreground">
|
||||
{userName}
|
||||
</p>
|
||||
{userEmail ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userEmail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="#profile"
|
||||
role="menuitem"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-card-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{headerRight}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-background ">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ReactNode } from "react";
|
||||
import { type MouseEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
children?: SidebarItem[];
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -12,41 +14,174 @@ export interface SidebarProps {
|
||||
items: SidebarItem[];
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
headerExtra?: ReactNode;
|
||||
}
|
||||
|
||||
const Sidebar = ({ title, items, activeHref, onNavigate }: SidebarProps) => (
|
||||
<aside className="flex w-60 flex-col gap-1 border-r border-gray-200 bg-white px-3 py-4">
|
||||
{title ? (
|
||||
<div className="px-2 pb-3 text-sm font-semibold text-gray-700">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(event) => {
|
||||
if (onNavigate) {
|
||||
event.preventDefault();
|
||||
onNavigate(item.href);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors",
|
||||
activeHref === item.href
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className="text-gray-500">{item.icon}</span>
|
||||
) : null}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
/**
|
||||
* Brand palette
|
||||
* #10B981 — dominant dark green → brand mark, active state, hover text
|
||||
* #10B981 — secondary green → hover background tint
|
||||
* #10B981 — muted green tone → sidebar canvas
|
||||
*/
|
||||
const Sidebar = ({
|
||||
title,
|
||||
items,
|
||||
activeHref,
|
||||
onNavigate,
|
||||
headerExtra,
|
||||
}: SidebarProps) => {
|
||||
const activePath = activeHref?.toLowerCase() ?? "";
|
||||
const defaultExpanded = useMemo(
|
||||
() =>
|
||||
items.reduce<Record<string, boolean>>((acc, item) => {
|
||||
if (item.children?.length) {
|
||||
acc[item.href] =
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(`${item.href.toLowerCase()}/`) ||
|
||||
item.children.some((child) =>
|
||||
activePath.startsWith(child.href.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
[activePath, items],
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||
}, [defaultExpanded]);
|
||||
|
||||
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (onNavigate) {
|
||||
event.preventDefault();
|
||||
onNavigate(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 flex-col gap-1 border-r border-sidebar-border bg-sidebar px-3 py-5 ">
|
||||
{title ? (
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sidebar-primary text-sm font-bold text-white">
|
||||
{title.charAt(0)}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const itemHref = item.href.toLowerCase();
|
||||
const childActive =
|
||||
item.children?.some((child) =>
|
||||
activePath.startsWith(child.href.toLowerCase()),
|
||||
) ?? false;
|
||||
const isCurrentItem = hasChildren
|
||||
? activePath === itemHref
|
||||
: activePath === itemHref || activePath.startsWith(`${itemHref}/`);
|
||||
const isSectionActive = childActive && !isCurrentItem;
|
||||
|
||||
return (
|
||||
<div key={item.href} className="flex flex-col gap-1">
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex items-center gap-2 rounded-md transition",
|
||||
isCurrentItem
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: isSectionActive
|
||||
? "bg-sidebar-accent/70 text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(event) => navigateTo(event, item.href)}
|
||||
aria-current={isCurrentItem ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
||||
>
|
||||
{item.icon ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||
isCurrentItem
|
||||
? "text-white"
|
||||
: isSectionActive
|
||||
? "text-[#10B981] dark:text-emerald-300"
|
||||
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</a>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle ${item.label}`}
|
||||
aria-expanded={expanded[item.href] ?? false}
|
||||
onClick={() =>
|
||||
setExpanded((current) => ({
|
||||
...current,
|
||||
[item.href]: !current[item.href],
|
||||
}))
|
||||
}
|
||||
className={clsx(
|
||||
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
|
||||
isCurrentItem
|
||||
? "text-white/90 hover:bg-white/10"
|
||||
: isSectionActive
|
||||
? "text-[#10B981] hover:bg-sidebar-accent/80 dark:text-emerald-300"
|
||||
: "text-slate-500 hover:bg-sidebar-accent/60 dark:text-slate-400",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
"h-4 w-4 transition-transform",
|
||||
expanded[item.href] ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasChildren && expanded[item.href] ? (
|
||||
<div className="ml-4 flex flex-col gap-1 border-l border-sidebar-border/60 pl-3">
|
||||
{item.children!.map((child) => {
|
||||
const childActiveHref = activePath === child.href.toLowerCase();
|
||||
|
||||
return (
|
||||
<a
|
||||
key={child.href}
|
||||
href={child.href}
|
||||
onClick={(event) => navigateTo(event, child.href)}
|
||||
aria-current={childActiveHref ? "page" : undefined}
|
||||
className={clsx(
|
||||
"rounded-md px-3 py-2 text-sm transition",
|
||||
childActiveHref
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{child.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
426
packages/ui-common/src/components/SmartFileInput/index.tsx
Normal file
426
packages/ui-common/src/components/SmartFileInput/index.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useMemo, useRef } from "react";
|
||||
import {
|
||||
IFileUploadSetting,
|
||||
IFileUploadField,
|
||||
} from "@edr/types/freight";
|
||||
import {
|
||||
UploadCloud,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
File,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../button";
|
||||
|
||||
export interface SmartFileInputProps {
|
||||
/** The settings object containing features and their upload fields config. */
|
||||
file: IFileUploadSetting;
|
||||
/** Controlled value: maps each fileKey to the selected File or File[]. */
|
||||
value?: Record<string, File | File[] | null>;
|
||||
/** Callback triggered when any field's files change. */
|
||||
onChange?: (value: Record<string, File | File[] | null>) => void;
|
||||
/** External form errors mapped by fileKey. */
|
||||
errors?: Record<string, string>;
|
||||
/** Disabled state for the entire file input group. */
|
||||
disabled?: boolean;
|
||||
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
||||
variant?: "default" | "minimal";
|
||||
/** Optional custom container CSS classes. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Helper to format file sizes in bytes to a human-readable string. */
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/** Render a suitable icon based on file extension. */
|
||||
function FileIcon({ name, className }: { name: string; className?: string }) {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (ext === "pdf") {
|
||||
return <FileText className={cn("text-red-500", className)} />;
|
||||
}
|
||||
|
||||
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
|
||||
return <ImageIcon className={cn("text-blue-500", className)} />;
|
||||
}
|
||||
|
||||
if (["csv", "xls", "xlsx"].includes(ext)) {
|
||||
return <FileText className={cn("text-emerald-500", className)} />;
|
||||
}
|
||||
|
||||
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
|
||||
return <File className={cn("text-amber-500", className)} />;
|
||||
}
|
||||
|
||||
return <File className={cn("text-slate-400", className)} />;
|
||||
}
|
||||
|
||||
export function SmartFileInput({
|
||||
file,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
disabled = false,
|
||||
variant = "default",
|
||||
className,
|
||||
}: SmartFileInputProps) {
|
||||
// Local state to manage files when the component is used in an uncontrolled manner
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({});
|
||||
|
||||
// Local validation errors
|
||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Drag-and-drop state active per field
|
||||
const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
|
||||
|
||||
// File input refs for programmatic clicks in minimal variant
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
// Memoize fields sorted by the order property (ascending)
|
||||
const sortedFields = useMemo(() => {
|
||||
return [...file.fields].sort((a, b) => a.order - b.order);
|
||||
}, [file.fields]);
|
||||
|
||||
// Create a map of fields for quick lookup
|
||||
const fieldsMap = useMemo(() => {
|
||||
return file.fields.reduce((acc, currentField) => {
|
||||
acc[currentField.fileKey] = currentField;
|
||||
return acc;
|
||||
}, {} as Record<string, IFileUploadField>);
|
||||
}, [file.fields]);
|
||||
|
||||
// Resolve current files list for a field
|
||||
const getFilesForField = (fieldKey: string): File[] => {
|
||||
const val = value ? value[fieldKey] : internalFiles[fieldKey];
|
||||
if (!val) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
};
|
||||
|
||||
const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
|
||||
const field = fieldsMap[fieldKey];
|
||||
if (!field) return;
|
||||
|
||||
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null);
|
||||
|
||||
if (onChange) {
|
||||
const updatedValues = {
|
||||
...(value || {}),
|
||||
[fieldKey]: newValue,
|
||||
};
|
||||
onChange(updatedValues);
|
||||
} else {
|
||||
setInternalFiles((prev) => ({
|
||||
...prev,
|
||||
[fieldKey]: newFiles,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
|
||||
const currentFiles = getFilesForField(field.fileKey);
|
||||
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
|
||||
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
|
||||
const allowedExts = field.allowedExtensions.map((ext) =>
|
||||
ext.toLowerCase().replace(/^\./, "")
|
||||
);
|
||||
|
||||
let validIncoming: File[] = [];
|
||||
let errorMsg = "";
|
||||
|
||||
for (const fileObj of incomingFiles) {
|
||||
const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
|
||||
const isExtValid =
|
||||
allowedExts.length === 0 || allowedExts.includes(ext);
|
||||
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
|
||||
|
||||
if (!isExtValid) {
|
||||
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
|
||||
", "
|
||||
)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isSizeValid) {
|
||||
errorMsg = `File size exceeds the limit of ${field.maxSizeMb}MB`;
|
||||
break;
|
||||
}
|
||||
|
||||
validIncoming.push(fileObj);
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
setLocalErrors((prev) => ({ ...prev, [field.fileKey]: errorMsg }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear local error on successful file addition
|
||||
setLocalErrors((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[field.fileKey];
|
||||
return updated;
|
||||
});
|
||||
|
||||
let newFilesList: File[] = [];
|
||||
if (field.isMultiple) {
|
||||
newFilesList = [...currentFiles, ...validIncoming].slice(0, maxAllowed);
|
||||
if (currentFiles.length + validIncoming.length > maxAllowed) {
|
||||
setLocalErrors((prev) => ({
|
||||
...prev,
|
||||
[field.fileKey]: `Only up to ${maxAllowed} files are allowed for this field. Excess files were ignored.`,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
newFilesList = validIncoming.slice(0, 1);
|
||||
}
|
||||
|
||||
handleFilesChange(field.fileKey, newFilesList);
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
setDragActive((prev) => ({ ...prev, [fieldKey]: active }));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, field: IFileUploadField) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
setDragActive((prev) => ({ ...prev, [field.fileKey]: false }));
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const filesArray = Array.from(e.dataTransfer.files);
|
||||
processFiles(field, filesArray);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: IFileUploadField
|
||||
) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
processFiles(field, filesArray);
|
||||
e.target.value = ""; // reset so same file can be selected again
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (fieldKey: string, indexToRemove: number) => {
|
||||
if (disabled) return;
|
||||
const currentFiles = getFilesForField(fieldKey);
|
||||
const updatedFiles = currentFiles.filter((_, idx) => idx !== indexToRemove);
|
||||
|
||||
const field = fieldsMap[fieldKey];
|
||||
if (field && field.isRequired && updatedFiles.length === 0) {
|
||||
setLocalErrors((prev) => ({
|
||||
...prev,
|
||||
[fieldKey]: "This file is required",
|
||||
}));
|
||||
} else {
|
||||
setLocalErrors((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[fieldKey];
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
handleFilesChange(fieldKey, updatedFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)}>
|
||||
{file.description && (
|
||||
<div className="text-sm text-muted-foreground border-b border-border pb-3">
|
||||
{file.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFields.map((field) => {
|
||||
const currentFiles = getFilesForField(field.fileKey);
|
||||
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
const reachedLimit = currentFiles.length >= maxFiles;
|
||||
const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey];
|
||||
const isDragOver = dragActive[field.fileKey];
|
||||
|
||||
// Format accepted files for the HTML input element
|
||||
const acceptString = field.allowedExtensions
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
|
||||
return (
|
||||
<div key={field.id || field.fileKey} className="flex flex-col gap-2">
|
||||
{/* Field Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-1">
|
||||
{field.fileLabel}
|
||||
{field.isRequired && (
|
||||
<span className="text-destructive font-bold" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max size: {field.maxSizeMb}MB
|
||||
{field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Help / Description Text */}
|
||||
{field.helpText && (
|
||||
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
||||
)}
|
||||
|
||||
{/* Selected Files List */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{currentFiles.map((fileObj, idx) => (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
||||
fieldError ? "border-destructive/30" : "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropzone area */}
|
||||
{!reachedLimit && (
|
||||
variant === "minimal" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => fileInputRefs.current[field.fileKey]?.click()}
|
||||
className="gap-1.5 cursor-pointer"
|
||||
>
|
||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Upload File</span>
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={(el) => {
|
||||
if (fileInputRefs.current) {
|
||||
fileInputRefs.current[field.fileKey] = el;
|
||||
}
|
||||
}}
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={cn(
|
||||
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
||||
fieldError && "border-destructive hover:border-destructive/80",
|
||||
disabled && "opacity-50 pointer-events-none cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
id={`file-input-${field.fileKey}`}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
|
||||
<UploadCloud className={cn("h-6 w-6 text-muted-foreground", isDragOver && "text-primary animate-bounce")} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Drag & drop your file here, or <span className="text-primary font-bold hover:underline">browse</span>
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Validation Error Message */}
|
||||
{fieldError && (
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span>{fieldError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmartFileInput;
|
||||
48
packages/ui-common/src/components/badge.tsx
Normal file
48
packages/ui-common/src/components/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
packages/ui-common/src/components/button.tsx
Normal file
64
packages/ui-common/src/components/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
packages/ui-common/src/components/card.tsx
Normal file
92
packages/ui-common/src/components/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
40
packages/ui-common/src/components/data-table/error.tsx
Normal file
40
packages/ui-common/src/components/data-table/error.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Table as TanstackTable } from "@tanstack/react-table";
|
||||
|
||||
import { TableCell, TableRow } from "../table";
|
||||
import { Button } from "../button";
|
||||
|
||||
export function DataTableError({
|
||||
table,
|
||||
message,
|
||||
description,
|
||||
onRetry,
|
||||
}: {
|
||||
table: TanstackTable<any>;
|
||||
message?: string;
|
||||
description?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getVisibleFlatColumns().length}>
|
||||
<div className="flex items-center my-6 justify-center">
|
||||
<div className="text-center">
|
||||
<div className="my-4">
|
||||
<h2 className="text-xl font-semibold ">
|
||||
{message ?? "No results"}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-secondary-foreground">
|
||||
{description ?? "No results found"}
|
||||
</p>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button variant={"outline"} onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
119
packages/ui-common/src/components/data-table/footer.tsx
Normal file
119
packages/ui-common/src/components/data-table/footer.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Button } from "../button";
|
||||
import { DataTableFooterProps } from "./types";
|
||||
|
||||
export interface DataTableFooterOptions {
|
||||
pageSizeOptions?: number[];
|
||||
showPageSizeSelector?: boolean;
|
||||
showRowCount?: boolean;
|
||||
showPagination?: boolean;
|
||||
labels?: {
|
||||
rowsPerPage?: string;
|
||||
page?: string;
|
||||
of?: string;
|
||||
showing?: string;
|
||||
ofLabel?: string;
|
||||
items?: string;
|
||||
previous?: string;
|
||||
next?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DataTableFooterComponentProps<
|
||||
TData,
|
||||
> extends DataTableFooterProps<TData> {
|
||||
options?: DataTableFooterOptions;
|
||||
}
|
||||
|
||||
const defaultOptions: DataTableFooterOptions = {
|
||||
pageSizeOptions: [5, 10, 25, 50],
|
||||
showPageSizeSelector: true,
|
||||
showRowCount: true,
|
||||
showPagination: true,
|
||||
labels: {
|
||||
rowsPerPage: "Rows per page",
|
||||
page: "Page",
|
||||
of: "of",
|
||||
showing: "Showing",
|
||||
ofLabel: "of",
|
||||
items: "items",
|
||||
previous: "Previous",
|
||||
next: "Next",
|
||||
},
|
||||
};
|
||||
|
||||
export function DataTableFooter<TData>({
|
||||
table,
|
||||
pagination,
|
||||
options = {},
|
||||
}: DataTableFooterComponentProps<TData>) {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
const labels = { ...defaultOptions.labels, ...options.labels };
|
||||
|
||||
const pageIndex = pagination.pageIndex ?? 0;
|
||||
const pageSize = pagination.pageSize ?? 10;
|
||||
const totalCount = pagination.totalCount ?? 0;
|
||||
|
||||
const start = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, totalCount);
|
||||
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
table?.setPageSize(newPageSize);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 max-sm:flex-col max-sm:items-start">
|
||||
{(opts.showPageSizeSelector || opts.showRowCount) && (
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
||||
{opts.showPageSizeSelector && (
|
||||
<>
|
||||
<label htmlFor="page-size" className="font-medium text-slate-700">
|
||||
{labels.rowsPerPage}
|
||||
</label>
|
||||
<select
|
||||
id="page-size"
|
||||
value={pageSize}
|
||||
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||
>
|
||||
{opts.pageSizeOptions?.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{opts.showRowCount && (
|
||||
<span>
|
||||
{labels.showing} {start}–{end} {labels.ofLabel} {totalCount}{" "}
|
||||
{labels.items}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{opts.showPagination && (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table?.previousPage()}
|
||||
disabled={!table?.getCanPreviousPage()}
|
||||
>
|
||||
{labels.previous}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table?.nextPage()}
|
||||
disabled={!table?.getCanNextPage()}
|
||||
>
|
||||
{labels.next}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
packages/ui-common/src/components/data-table/hooks.ts
Normal file
25
packages/ui-common/src/components/data-table/hooks.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PaginationState } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
export const usePagination = ({
|
||||
pageSize,
|
||||
pageIndex,
|
||||
}: {
|
||||
pageSize?: number;
|
||||
pageIndex?: number;
|
||||
} = {}) => {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: pageIndex ?? 0,
|
||||
pageSize: pageSize ?? 10,
|
||||
});
|
||||
|
||||
const setPage = (pageIndex: number) => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex }));
|
||||
};
|
||||
|
||||
return {
|
||||
pagination,
|
||||
setPagination,
|
||||
setPage,
|
||||
};
|
||||
};
|
||||
12
packages/ui-common/src/components/data-table/index.ts
Normal file
12
packages/ui-common/src/components/data-table/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { DataTableError } from "./error";
|
||||
export { DataTableSkeleton } from "./skeleton";
|
||||
export { DataTableFooter, type DataTableFooterOptions } from "./footer";
|
||||
export type {
|
||||
DataTableProps,
|
||||
DataTablePagination,
|
||||
DataTableFooterProps,
|
||||
DataTableFooterComponent,
|
||||
} from "./types";
|
||||
export { usePagination } from "./hooks";
|
||||
export { DataTable } from "./table";
|
||||
export * from "@tanstack/react-table";
|
||||
16
packages/ui-common/src/components/data-table/skeleton.tsx
Normal file
16
packages/ui-common/src/components/data-table/skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Table as TanstackTable } from "@tanstack/react-table";
|
||||
|
||||
import { Skeleton } from "../skeleton";
|
||||
import { TableCell, TableRow } from "../table";
|
||||
|
||||
export function DataTableSkeleton({ table }: { table: TanstackTable<any> }) {
|
||||
return Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{table.getAllColumns().map((column) => (
|
||||
<TableCell key={column.id}>
|
||||
<Skeleton className="h-8" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
172
packages/ui-common/src/components/data-table/table.tsx
Normal file
172
packages/ui-common/src/components/data-table/table.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../table";
|
||||
import { DataTableProps } from "./types";
|
||||
import { DataTableSkeleton } from "./skeleton";
|
||||
import { DataTableError } from "./error";
|
||||
import { DataTableFooter } from "./footer";
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
status,
|
||||
onRowClick,
|
||||
tableOptions,
|
||||
pagination,
|
||||
footer,
|
||||
footerClassName,
|
||||
containerClassName,
|
||||
error,
|
||||
emptyMessage,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const { state, ...otherOptions } = tableOptions ?? {};
|
||||
const baseState = state ?? {};
|
||||
if (pagination) {
|
||||
baseState.pagination = {
|
||||
pageIndex: pagination.pageIndex ?? 0,
|
||||
pageSize: pagination.pageSize ?? 1,
|
||||
};
|
||||
}
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
||||
manualPagination: true,
|
||||
...(pagination && {
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
pageCount: pagination.pageCount,
|
||||
}),
|
||||
state: baseState,
|
||||
...otherOptions,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={containerClassName}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
(header.column.columnDef.meta as Record<string, any>)
|
||||
?.headerClassName ?? ""
|
||||
}
|
||||
style={{ width: `${header.getSize()}px` }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{status === "loading" && <DataTableSkeleton table={table} />}
|
||||
{status === "error" && (
|
||||
<DataTableError
|
||||
table={table}
|
||||
message={error?.message ?? "Something went wrong"}
|
||||
description={error?.description ?? "Please try again"}
|
||||
onRetry={error?.onRetry}
|
||||
/>
|
||||
)}
|
||||
{status === "success" &&
|
||||
(table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={(event) => {
|
||||
if (!onRowClick) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.closest(
|
||||
[
|
||||
"button",
|
||||
"a",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[role='menu']",
|
||||
"[role='menuitem']",
|
||||
"[data-slot='dialog-content']",
|
||||
"[data-slot='dialog-overlay']",
|
||||
"[data-slot='dropdown-menu-content']",
|
||||
"[data-stop-row-click]",
|
||||
].join(","),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onRowClick(row.original);
|
||||
}}
|
||||
role={onRowClick ? "button" : ""}
|
||||
className={
|
||||
onRowClick
|
||||
? "cursor-pointer hover:bg-accent hover:text-foreground "
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(cell.column.columnDef.meta as Record<string, any>)
|
||||
?.cellClassName ?? ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleFlatColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{emptyMessage ?? "No data"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className={footerClassName}>
|
||||
{footer ? (
|
||||
footer({ table, pagination })
|
||||
) : (
|
||||
<DataTableFooter table={table} pagination={pagination} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
packages/ui-common/src/components/data-table/types.ts
Normal file
38
packages/ui-common/src/components/data-table/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Table as TanstackTable, TableOptions, ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
export interface DataTablePagination {
|
||||
pageSize?: number;
|
||||
pageIndex?: number;
|
||||
pageCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export interface DataTableFooterProps<TData> {
|
||||
table: TanstackTable<TData>;
|
||||
pagination: DataTablePagination;
|
||||
}
|
||||
|
||||
export type DataTableFooterComponent<TData> = (
|
||||
props: DataTableFooterProps<TData>
|
||||
) => React.ReactNode;
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
status?: "loading" | "error" | "success";
|
||||
onRowClick?: (row: TData) => void;
|
||||
tableOptions?: Omit<
|
||||
TableOptions<TData>,
|
||||
"data" | "columns" | "getCoreRowModel"
|
||||
>;
|
||||
pagination?: DataTablePagination;
|
||||
footer?: DataTableFooterComponent<TData>;
|
||||
footerClassName?: string;
|
||||
containerClassName?: string;
|
||||
emptyMessage?: string;
|
||||
error?: {
|
||||
message: string;
|
||||
description?: string;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
}
|
||||
156
packages/ui-common/src/components/dialog.tsx
Normal file
156
packages/ui-common/src/components/dialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
import { Button } from "./button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
255
packages/ui-common/src/components/dropdown-menu.tsx
Normal file
255
packages/ui-common/src/components/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
246
packages/ui-common/src/components/field.tsx
Normal file
246
packages/ui-common/src/components/field.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
import { Label } from "./label"
|
||||
import { Separator } from "./separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
21
packages/ui-common/src/components/input.tsx
Normal file
21
packages/ui-common/src/components/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
22
packages/ui-common/src/components/label.tsx
Normal file
22
packages/ui-common/src/components/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
190
packages/ui-common/src/components/select.tsx
Normal file
190
packages/ui-common/src/components/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
packages/ui-common/src/components/separator.tsx
Normal file
28
packages/ui-common/src/components/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
13
packages/ui-common/src/components/skeleton.tsx
Normal file
13
packages/ui-common/src/components/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
33
packages/ui-common/src/components/switch.tsx
Normal file
33
packages/ui-common/src/components/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 p-0.5 h-auto data-[size=lg]:w-10 data-[size=default]:w-8 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=lg]/switch:size-5 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-7px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
114
packages/ui-common/src/components/table.tsx
Normal file
114
packages/ui-common/src/components/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b outline-ring/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors bg-background hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 bg-muted first-of-type:pl-4 last-of-type:pr-4 first-of-type: p-2 py-4 text-left align-middle font-medium whitespace-nowrap text-secondary-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle first-of-type:pl-4 last-of-type:pr-4 whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
18
packages/ui-common/src/components/textarea.tsx
Normal file
18
packages/ui-common/src/components/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,27 +1,37 @@
|
||||
export { default as Button } from "./components/Button";
|
||||
export type {
|
||||
ButtonProps,
|
||||
ButtonVariant,
|
||||
ButtonSize,
|
||||
} from "./components/Button";
|
||||
|
||||
export { default as Table } from "./components/Table";
|
||||
export type { TableProps, TableColumn } from "./components/Table";
|
||||
export { Table } from "./components/table";
|
||||
export type { TableProps, TableColumn } from "./components/Table/Table";
|
||||
|
||||
export { FormField } from "./components/Form";
|
||||
export type { FormFieldProps } from "./components/Form";
|
||||
|
||||
export { default as SmartFileInput } from "./components/SmartFileInput";
|
||||
export type { SmartFileInputProps } from "./components/SmartFileInput";
|
||||
|
||||
export { default as Modal } from "./components/Modal";
|
||||
export type { ModalProps } from "./components/Modal";
|
||||
|
||||
export { default as Badge } from "./components/Badge";
|
||||
export type { BadgeProps, BadgeTone } from "./components/Badge";
|
||||
export { Badge } from "./components/badge";
|
||||
// export type { BadgeProps } from "./components/badge";
|
||||
|
||||
export { Sidebar, DashboardLayout } from "./components/Layout";
|
||||
|
||||
export type {
|
||||
SidebarProps,
|
||||
SidebarItem,
|
||||
DashboardLayoutProps,
|
||||
} from "./components/Layout";
|
||||
|
||||
export * from "./theme";
|
||||
export * from "./components/button";
|
||||
export * from "./components/input";
|
||||
export * from "./components/skeleton";
|
||||
export * from "./components/textarea";
|
||||
export * from "./components/label";
|
||||
export * from "./components/card";
|
||||
export * from "./components/dropdown-menu";
|
||||
export * from "./components/data-table";
|
||||
export * from "./components/dialog";
|
||||
export * from "./components/SmartFileInput";
|
||||
export * from "./components/select";
|
||||
export * from "./components/switch";
|
||||
export * from "./components/separator";
|
||||
export * from "./components/field";
|
||||
|
||||
6
packages/ui-common/src/lib/utils.ts
Normal file
6
packages/ui-common/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
4
packages/ui-common/src/styles/index.css
Normal file
4
packages/ui-common/src/styles/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./theme.css";
|
||||
@custom-variant dark (&: where(.dark, .dark *));
|
||||
230
packages/ui-common/src/styles/theme.css
Normal file
230
packages/ui-common/src/styles/theme.css
Normal file
@@ -0,0 +1,230 @@
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(0.99 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.596 0.1274 163.23);
|
||||
--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.9753 0.0148 149.37);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.81 0.1 252);
|
||||
--chart-2: oklch(0.62 0.19 260);
|
||||
--chart-3: oklch(0.55 0.22 263);
|
||||
--chart-4: oklch(0.49 0.22 264);
|
||||
--chart-5: oklch(0.42 0.18 266);
|
||||
--sidebar: oklch(0.986 0.0068 174.38);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.596 0.1274 163.23);
|
||||
--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);
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--radius: 0.8rem;
|
||||
--shadow-x: 0;
|
||||
--shadow-y: 1px;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.1;
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-md:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.269 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: #0d5c2c;
|
||||
--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.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.275 0 0);
|
||||
--input: oklch(0.325 0 0);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.81 0.1 252);
|
||||
--chart-2: oklch(0.62 0.19 260);
|
||||
--chart-3: oklch(0.55 0.22 263);
|
||||
--chart-4: oklch(0.49 0.22 264);
|
||||
--chart-5: oklch(0.42 0.18 266);
|
||||
--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.275 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--shadow-x: 0;
|
||||
--shadow-y: 1px;
|
||||
--shadow-blur: 3px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-opacity: 0.1;
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-md:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
|
||||
@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);
|
||||
--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);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(203 213 225 / 0.6) transparent;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(203 213 225 / 0.7);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(51 87 141 / 0.5);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: rgb(71 85 105 / 0.6) transparent;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(71 85 105 / 0.6);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(51 87 141 / 0.7);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
export const colors = {
|
||||
primary: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
neutral: {
|
||||
50: "#f9fafb",
|
||||
100: "#f3f4f6",
|
||||
200: "#e5e7eb",
|
||||
400: "#9ca3af",
|
||||
600: "#4b5563",
|
||||
900: "#111827",
|
||||
},
|
||||
success: "#16a34a",
|
||||
warning: "#f59e0b",
|
||||
danger: "#dc2626",
|
||||
info: "#0ea5e9",
|
||||
} as const;
|
||||
|
||||
export type Colors = typeof colors;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./colors";
|
||||
export * from "./typography";
|
||||
@@ -1,28 +0,0 @@
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: '"Inter", "Segoe UI", sans-serif',
|
||||
mono: '"Fira Code", "Menlo", monospace',
|
||||
},
|
||||
fontSize: {
|
||||
xs: "0.75rem",
|
||||
sm: "0.875rem",
|
||||
base: "1rem",
|
||||
lg: "1.125rem",
|
||||
xl: "1.25rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
},
|
||||
fontWeight: {
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
},
|
||||
lineHeight: {
|
||||
tight: 1.25,
|
||||
normal: 1.5,
|
||||
relaxed: 1.75,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Typography = typeof typography;
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#components": ["src/components"],
|
||||
"#components/*": ["src/components/*"],
|
||||
"#lib": ["src/lib"],
|
||||
"#lib/*": ["src/lib/*"],
|
||||
"#hooks": ["src/hooks"],
|
||||
"#hooks/*": ["src/hooks/*"]
|
||||
},
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
|
||||
22
packages/ui-common/turbo.json
Normal file
22
packages/ui-common/turbo.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["build:styles"]
|
||||
},
|
||||
"build:styles": {
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"with": ["dev:styles",]
|
||||
},
|
||||
"dev:styles": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"dev:components": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user