Add business nickname support across app and API

This commit is contained in:
2025-08-11 01:50:20 -04:00
parent 93ffdf3c86
commit a680f89a46
19 changed files with 308 additions and 1193 deletions

View File

@@ -1,130 +0,0 @@
CREATE TABLE "beenvoice_account" (
"userId" varchar(255) NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"providerAccountId" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255),
CONSTRAINT "beenvoice_account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE "beenvoice_business" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"website" varchar(255),
"taxId" varchar(100),
"logoUrl" varchar(500),
"isDefault" boolean DEFAULT false,
"resendApiKey" varchar(255),
"resendDomain" varchar(255),
"emailFromName" varchar(255),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_client" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255),
"phone" varchar(50),
"addressLine1" varchar(255),
"addressLine2" varchar(255),
"city" varchar(100),
"state" varchar(50),
"postalCode" varchar(20),
"country" varchar(100),
"defaultHourlyRate" real DEFAULT 100 NOT NULL,
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice_item" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceId" varchar(255) NOT NULL,
"date" timestamp NOT NULL,
"description" varchar(500) NOT NULL,
"hours" real NOT NULL,
"rate" real NOT NULL,
"amount" real NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_invoice" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"invoiceNumber" varchar(100) NOT NULL,
"businessId" varchar(255),
"clientId" varchar(255) NOT NULL,
"issueDate" timestamp NOT NULL,
"dueDate" timestamp NOT NULL,
"status" varchar(50) DEFAULT 'draft' NOT NULL,
"totalAmount" real DEFAULT 0 NOT NULL,
"taxRate" real DEFAULT 0 NOT NULL,
"notes" varchar(1000),
"createdById" varchar(255) NOT NULL,
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
CREATE TABLE "beenvoice_session" (
"sessionToken" varchar(255) PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "beenvoice_user" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255),
"email" varchar(255) NOT NULL,
"password" varchar(255),
"emailVerified" timestamp DEFAULT CURRENT_TIMESTAMP,
"image" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "beenvoice_verification_token" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "beenvoice_verification_token_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
ALTER TABLE "beenvoice_account" ADD CONSTRAINT "beenvoice_account_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_business" ADD CONSTRAINT "beenvoice_business_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_client" ADD CONSTRAINT "beenvoice_client_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_invoice" ADD CONSTRAINT "beenvoice_invoice_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "beenvoice_session" ADD CONSTRAINT "beenvoice_session_userId_beenvoice_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_user_id_idx" ON "beenvoice_account" USING btree ("userId");--> statement-breakpoint
CREATE INDEX "business_created_by_idx" ON "beenvoice_business" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "business_name_idx" ON "beenvoice_business" USING btree ("name");--> statement-breakpoint
CREATE INDEX "business_email_idx" ON "beenvoice_business" USING btree ("email");--> statement-breakpoint
CREATE INDEX "business_is_default_idx" ON "beenvoice_business" USING btree ("isDefault");--> statement-breakpoint
CREATE INDEX "client_created_by_idx" ON "beenvoice_client" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "client_name_idx" ON "beenvoice_client" USING btree ("name");--> statement-breakpoint
CREATE INDEX "client_email_idx" ON "beenvoice_client" USING btree ("email");--> statement-breakpoint
CREATE INDEX "invoice_item_invoice_id_idx" ON "beenvoice_invoice_item" USING btree ("invoiceId");--> statement-breakpoint
CREATE INDEX "invoice_item_date_idx" ON "beenvoice_invoice_item" USING btree ("date");--> statement-breakpoint
CREATE INDEX "invoice_item_position_idx" ON "beenvoice_invoice_item" USING btree ("position");--> statement-breakpoint
CREATE INDEX "invoice_business_id_idx" ON "beenvoice_invoice" USING btree ("businessId");--> statement-breakpoint
CREATE INDEX "invoice_client_id_idx" ON "beenvoice_invoice" USING btree ("clientId");--> statement-breakpoint
CREATE INDEX "invoice_created_by_idx" ON "beenvoice_invoice" USING btree ("createdById");--> statement-breakpoint
CREATE INDEX "invoice_number_idx" ON "beenvoice_invoice" USING btree ("invoiceNumber");--> statement-breakpoint
CREATE INDEX "invoice_status_idx" ON "beenvoice_invoice" USING btree ("status");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "beenvoice_session" USING btree ("userId");

View File

@@ -1,995 +0,0 @@
{
"id": "c86256e7-28b4-48ea-8104-933cbff6e217",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.beenvoice_account": {
"name": "beenvoice_account",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_account_userId_beenvoice_user_id_fk": {
"name": "beenvoice_account_userId_beenvoice_user_id_fk",
"tableFrom": "beenvoice_account",
"tableTo": "beenvoice_user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"beenvoice_account_provider_providerAccountId_pk": {
"name": "beenvoice_account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_business": {
"name": "beenvoice_business",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"addressLine1": {
"name": "addressLine1",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"addressLine2": {
"name": "addressLine2",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"postalCode": {
"name": "postalCode",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"website": {
"name": "website",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"taxId": {
"name": "taxId",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"logoUrl": {
"name": "logoUrl",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false
},
"isDefault": {
"name": "isDefault",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"resendApiKey": {
"name": "resendApiKey",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"resendDomain": {
"name": "resendDomain",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"emailFromName": {
"name": "emailFromName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"createdById": {
"name": "createdById",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"business_created_by_idx": {
"name": "business_created_by_idx",
"columns": [
{
"expression": "createdById",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"business_name_idx": {
"name": "business_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"business_email_idx": {
"name": "business_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"business_is_default_idx": {
"name": "business_is_default_idx",
"columns": [
{
"expression": "isDefault",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_business_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_business_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_business",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_client": {
"name": "beenvoice_client",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"addressLine1": {
"name": "addressLine1",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"addressLine2": {
"name": "addressLine2",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"postalCode": {
"name": "postalCode",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"defaultHourlyRate": {
"name": "defaultHourlyRate",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 100
},
"createdById": {
"name": "createdById",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"client_created_by_idx": {
"name": "client_created_by_idx",
"columns": [
{
"expression": "createdById",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"client_name_idx": {
"name": "client_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"client_email_idx": {
"name": "client_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_client_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_client_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_client",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_invoice_item": {
"name": "beenvoice_invoice_item",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"invoiceId": {
"name": "invoiceId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true
},
"hours": {
"name": "hours",
"type": "real",
"primaryKey": false,
"notNull": true
},
"rate": {
"name": "rate",
"type": "real",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "real",
"primaryKey": false,
"notNull": true
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"invoice_item_invoice_id_idx": {
"name": "invoice_item_invoice_id_idx",
"columns": [
{
"expression": "invoiceId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_item_date_idx": {
"name": "invoice_item_date_idx",
"columns": [
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_item_position_idx": {
"name": "invoice_item_position_idx",
"columns": [
{
"expression": "position",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk": {
"name": "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk",
"tableFrom": "beenvoice_invoice_item",
"tableTo": "beenvoice_invoice",
"columnsFrom": [
"invoiceId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_invoice": {
"name": "beenvoice_invoice",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"invoiceNumber": {
"name": "invoiceNumber",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"businessId": {
"name": "businessId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"clientId": {
"name": "clientId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"issueDate": {
"name": "issueDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"dueDate": {
"name": "dueDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"totalAmount": {
"name": "totalAmount",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"taxRate": {
"name": "taxRate",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"notes": {
"name": "notes",
"type": "varchar(1000)",
"primaryKey": false,
"notNull": false
},
"createdById": {
"name": "createdById",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "CURRENT_TIMESTAMP"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"invoice_business_id_idx": {
"name": "invoice_business_id_idx",
"columns": [
{
"expression": "businessId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_client_id_idx": {
"name": "invoice_client_id_idx",
"columns": [
{
"expression": "clientId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_created_by_idx": {
"name": "invoice_created_by_idx",
"columns": [
{
"expression": "createdById",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_number_idx": {
"name": "invoice_number_idx",
"columns": [
{
"expression": "invoiceNumber",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"invoice_status_idx": {
"name": "invoice_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_invoice_businessId_beenvoice_business_id_fk": {
"name": "beenvoice_invoice_businessId_beenvoice_business_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_business",
"columnsFrom": [
"businessId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"beenvoice_invoice_clientId_beenvoice_client_id_fk": {
"name": "beenvoice_invoice_clientId_beenvoice_client_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_client",
"columnsFrom": [
"clientId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"beenvoice_invoice_createdById_beenvoice_user_id_fk": {
"name": "beenvoice_invoice_createdById_beenvoice_user_id_fk",
"tableFrom": "beenvoice_invoice",
"tableTo": "beenvoice_user",
"columnsFrom": [
"createdById"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_session": {
"name": "beenvoice_session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"beenvoice_session_userId_beenvoice_user_id_fk": {
"name": "beenvoice_session_userId_beenvoice_user_id_fk",
"tableFrom": "beenvoice_session",
"tableTo": "beenvoice_user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_user": {
"name": "beenvoice_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(255)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"image": {
"name": "image",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.beenvoice_verification_token": {
"name": "beenvoice_verification_token",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"beenvoice_verification_token_identifier_token_pk": {
"name": "beenvoice_verification_token_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1753825898609,
"tag": "0000_warm_squadron_sinister",
"breakpoints": true
}
]
}

View File

@@ -45,7 +45,7 @@ export default async function BusinessDetailPage({
return (
<div className="space-y-6 pb-32">
<PageHeader
title={business.name}
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information"
variant="gradient"
>
@@ -69,7 +69,7 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Business Information</span>
@@ -84,7 +84,7 @@ export default async function BusinessDetailPage({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{business.email && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<div>
@@ -100,7 +100,7 @@ export default async function BusinessDetailPage({
{business.phone && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<div>
@@ -116,7 +116,7 @@ export default async function BusinessDetailPage({
{business.website && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Globe className="text-primary h-4 w-4" />
</div>
<div>
@@ -137,7 +137,7 @@ export default async function BusinessDetailPage({
{business.taxId && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Hash className="text-primary h-4 w-4" />
</div>
<div>
@@ -162,7 +162,7 @@ export default async function BusinessDetailPage({
Business Address
</h3>
<div className="flex items-start space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<MapPin className="text-primary h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
@@ -205,7 +205,7 @@ export default async function BusinessDetailPage({
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
<div className="space-y-4">
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Calendar className="text-primary h-4 w-4" />
</div>
<div>
@@ -218,10 +218,31 @@ export default async function BusinessDetailPage({
</div>
</div>
{business.nickname && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">
Nickname
</p>
<Badge variant="outline" className="text-xs">
Internal only
</Badge>
</div>
<p className="text-foreground text-sm">
{business.nickname}
</p>
</div>
</div>
)}
{/* Default Business Badge */}
{business.isDefault && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
@@ -248,7 +269,7 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Quick Actions</span>

View File

@@ -22,6 +22,7 @@ import { toast } from "sonner";
interface Business {
id: string;
name: string;
nickname: string | null;
email: string | null;
phone: string | null;
addressLine1: string | null;
@@ -61,6 +62,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const utils = api.useUtils();
const searchableBusinesses = businesses.map((b) => ({
...b,
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
}));
const deleteBusinessMutation = api.businesses.delete.useMutation({
onSuccess: () => {
toast.success("Business deleted successfully");
@@ -91,7 +97,7 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const business = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-primary/10 hidden p-2 sm:flex">
<div className="bg-primary/10 hidden p-2 sm:flex">
<Building className="text-primary h-4 w-4" />
</div>
<div className="min-w-0">
@@ -104,6 +110,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
);
},
},
{
accessorKey: "nickname",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nickname" />
),
cell: ({ row }) => row.original.nickname ?? "—",
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "phone",
header: ({ column }) => (
@@ -175,6 +192,15 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
);
},
},
{
accessorKey: "searchValue",
header: "Search",
cell: () => null,
meta: {
headerClassName: "hidden",
cellClassName: "hidden",
},
},
{
id: "actions",
cell: ({ row }) => {
@@ -210,9 +236,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
<>
<DataTable
columns={columns}
data={businesses}
searchKey="name"
searchPlaceholder="Search businesses..."
data={searchableBusinesses}
searchKey="searchValue"
searchPlaceholder="Search by name or nickname..."
onRowClick={handleRowClick}
/>

View File

@@ -31,7 +31,7 @@ export default async function BusinessesPage() {
</PageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<BusinessesTable />
</Suspense>
</HydrateClient>

View File

@@ -165,6 +165,7 @@ export default function SendEmailPage() {
business: invoiceData.business
? {
name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email,
}
: undefined,

View File

@@ -98,6 +98,7 @@ export function DataTable<TData, TValue>({
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [globalFilter, setGlobalFilter] = React.useState("");
const [searchInput, setSearchInput] = React.useState("");
// Mobile detection hook
const [isMobile, setIsMobile] = React.useState(false);
@@ -171,6 +172,19 @@ export function DataTable<TData, TValue>({
table.setPageSize(isMobile ? 5 : pageSize);
}, [isMobile, pageSize, table]);
// Debounce search input updates to the table's global filter
React.useEffect(() => {
const timeout = setTimeout(() => {
setGlobalFilter(searchInput);
}, 300);
return () => clearTimeout(timeout);
}, [searchInput]);
// Keep search input in sync when globalFilter is changed externally (e.g., "Clear filters")
React.useEffect(() => {
setSearchInput(globalFilter ?? "");
}, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
// Handle row click
@@ -223,8 +237,8 @@ export function DataTable<TData, TValue>({
<Search className="text-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)}
value={searchInput ?? ""}
onChange={(event) => setSearchInput(event.target.value)}
className="h-9 w-full pr-3 pl-9"
/>
</div>

View File

@@ -188,7 +188,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex items-start justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<FileText className="text-primary h-6 w-6" />
</div>
<div>
@@ -239,7 +239,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
>
{isExportingPDF ? (
<>
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
Generating PDF...
</>
) : (
@@ -326,7 +326,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="border-border overflow-hidden border">
<div className="border-border overflow-hidden border">
<table className="w-full">
<thead className="bg-muted">
<tr>
@@ -479,7 +479,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Danger Zone */}
<Card className="bg-card border-border border border-destructive/20">
<Card className="bg-card border-destructive/20 border">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
</CardHeader>

View File

@@ -46,6 +46,7 @@ interface BusinessFormProps {
interface FormData {
name: string;
nickname: string;
email: string;
phone: string;
addressLine1: string;
@@ -64,6 +65,7 @@ interface FormData {
interface FormErrors {
name?: string;
nickname?: string;
email?: string;
phone?: string;
addressLine1?: string;
@@ -80,6 +82,7 @@ interface FormErrors {
const initialFormData: FormData = {
name: "",
nickname: "",
email: "",
phone: "",
addressLine1: "",
@@ -153,6 +156,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
if (business && mode === "edit") {
setFormData({
name: business.name,
nickname: business.nickname ?? "",
email: business.email ?? "",
phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "",
@@ -198,6 +202,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
if (!formData.name.trim()) {
newErrors.name = VALIDATION_MESSAGES.required;
}
// Nickname validation (optional, max 255 chars)
if (formData.nickname && formData.nickname.length > 255) {
newErrors.nickname = "Nickname must be 255 characters or less";
}
// Email validation
if (formData.email && !isValidEmail(formData.email)) {
@@ -280,6 +288,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Format website URL before submission
const dataToSubmit = {
...formData,
name: formData.name.trim(),
nickname: formData.nickname.trim(),
website: formData.website ? formatWebsiteUrl(formData.website) : "",
};
@@ -287,6 +297,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Create business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
nickname: dataToSubmit.nickname,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
@@ -320,6 +331,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Update business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
nickname: dataToSubmit.nickname,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
@@ -442,7 +454,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Building className="text-muted-foreground h-5 w-5" />
</div>
<div>
@@ -475,6 +487,29 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
)}
</div>
<div className="space-y-2">
<Label htmlFor="nickname" className="text-sm font-medium">
Nickname
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) =>
handleInputChange("nickname", e.target.value)
}
placeholder="e.g., Personal, Work, LLC"
disabled={isSubmitting}
/>
{errors.nickname && (
<p className="text-destructive text-sm">
{errors.nickname}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="taxId" className="text-sm font-medium">
Tax ID (EIN)
@@ -569,7 +604,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<svg
className="text-muted-foreground h-5 w-5"
fill="none"
@@ -617,7 +652,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Mail className="text-muted-foreground h-5 w-5" />
</div>
<div>
@@ -632,7 +667,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<CardContent className="space-y-6">
{/* Current Status */}
{mode === "edit" && (
<div className="flex items-center justify-between bg-gray-50 p-4">
<div className="flex items-center justify-between bg-gray-50 p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Current Status:
@@ -806,7 +841,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Star className="text-muted-foreground h-5 w-5" />
</div>
<div>
@@ -818,7 +853,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted border-border/40 flex items-center justify-between border p-4">
<div className="bg-muted border-border/40 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
htmlFor="isDefault"
@@ -848,7 +883,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<FloatingActionBar
leftContent={
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<FileText className="text-primary h-5 w-5" />
</div>
<div>

View File

@@ -624,16 +624,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
updateField("businessId", value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select your business" />
<SelectTrigger
aria-label="From Business"
className="w-full"
>
<span className="min-w-0 flex-1 truncate text-left">
<SelectValue placeholder="Select your business (nickname shown)" />
</span>
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[--radix-select-trigger-width] min-w-[--radix-select-trigger-width]">
{businesses?.map((business) => (
<SelectItem
key={business.id}
value={business.id}
className="truncate"
>
{business.name}
<span className="block truncate">{`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}</span>
</SelectItem>
))}
</SelectContent>
@@ -647,13 +653,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
updateField("clientId", value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a client" />
<SelectTrigger
aria-label="Bill To Client"
className="w-full"
>
<span className="min-w-0 flex-1 truncate text-left">
<SelectValue placeholder="Select a client" />
</span>
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[--radix-select-trigger-width] min-w-[--radix-select-trigger-width]">
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
<SelectItem
key={client.id}
value={client.id}
className="truncate"
>
<span className="block truncate">
{client.name}
</span>
</SelectItem>
))}
</SelectContent>
@@ -789,10 +806,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Business:</span>
<span className="text-muted-foreground">
Business (nickname shown):
</span>
<span className="font-medium">
{businesses?.find((b) => b.id === formData.businessId)
?.name ?? "Not selected"}
{(() => {
const b = businesses?.find(
(b) => b.id === formData.businessId,
);
return b
? `${b.name}${b.nickname ? ` (${b.nickname})` : ""}`
: "Not selected";
})()}
</span>
</div>
</div>

View File

@@ -42,14 +42,16 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-10 w-full items-center justify-between gap-2 border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 relative flex h-10 w-full items-center justify-start gap-2 border px-3 py-2 pr-8 text-left text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center">
<ChevronDownIcon className="size-4 opacity-50" />
</span>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@@ -66,7 +68,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -112,7 +114,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -210,7 +212,7 @@ function SelectContentWithSearch({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -235,7 +237,7 @@ function SelectContentWithSearch({
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}

View File

@@ -13,6 +13,7 @@ interface InvoiceEmailTemplateProps {
};
business?: {
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;

View File

@@ -20,6 +20,7 @@ interface InvoiceData {
notes?: string | null;
business?: {
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;

View File

@@ -6,7 +6,17 @@ import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm";
const businessSchema = z.object({
name: z.string().min(1, "Business name is required"),
name: z
.string()
.trim()
.min(1, "Business name is required")
.max(255, "Business name must be 255 characters or less"),
nickname: z
.string()
.trim()
.max(255, "Nickname must be 255 characters or less")
.optional()
.or(z.literal("")),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional().or(z.literal("")),
addressLine1: z.string().optional().or(z.literal("")),
@@ -96,7 +106,54 @@ export const businessesRouter = createTRPCRouter({
const [newBusiness] = await ctx.db
.insert(businesses)
.values({
...input,
name: input.name.trim(),
nickname:
input.nickname && input.nickname.trim() !== ""
? input.nickname.trim()
: null,
email:
input.email && input.email.trim() !== ""
? input.email.trim()
: null,
phone:
input.phone && input.phone.trim() !== ""
? input.phone.trim()
: null,
addressLine1:
input.addressLine1 && input.addressLine1.trim() !== ""
? input.addressLine1.trim()
: null,
addressLine2:
input.addressLine2 && input.addressLine2.trim() !== ""
? input.addressLine2.trim()
: null,
city:
input.city && input.city.trim() !== "" ? input.city.trim() : null,
state:
input.state && input.state.trim() !== ""
? input.state.trim()
: null,
postalCode:
input.postalCode && input.postalCode.trim() !== ""
? input.postalCode.trim()
: null,
country:
input.country && input.country.trim() !== ""
? input.country.trim()
: null,
website:
input.website && input.website.trim() !== ""
? input.website.trim()
: null,
taxId:
input.taxId && input.taxId.trim() !== ""
? input.taxId.trim()
: null,
logoUrl:
input.logoUrl && input.logoUrl.trim() !== ""
? input.logoUrl.trim()
: null,
isDefault: input.isDefault ?? false,
createdById: ctx.session.user.id,
})
.returning();
@@ -126,7 +183,56 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({
...updateData,
name: (updateData.name ?? "").trim(),
nickname:
updateData.nickname && updateData.nickname.trim() !== ""
? updateData.nickname.trim()
: null,
email:
updateData.email && updateData.email.trim() !== ""
? updateData.email.trim()
: null,
phone:
updateData.phone && updateData.phone.trim() !== ""
? updateData.phone.trim()
: null,
addressLine1:
updateData.addressLine1 && updateData.addressLine1.trim() !== ""
? updateData.addressLine1.trim()
: null,
addressLine2:
updateData.addressLine2 && updateData.addressLine2.trim() !== ""
? updateData.addressLine2.trim()
: null,
city:
updateData.city && updateData.city.trim() !== ""
? updateData.city.trim()
: null,
state:
updateData.state && updateData.state.trim() !== ""
? updateData.state.trim()
: null,
postalCode:
updateData.postalCode && updateData.postalCode.trim() !== ""
? updateData.postalCode.trim()
: null,
country:
updateData.country && updateData.country.trim() !== ""
? updateData.country.trim()
: null,
website:
updateData.website && updateData.website.trim() !== ""
? updateData.website.trim()
: null,
taxId:
updateData.taxId && updateData.taxId.trim() !== ""
? updateData.taxId.trim()
: null,
logoUrl:
updateData.logoUrl && updateData.logoUrl.trim() !== ""
? updateData.logoUrl.trim()
: null,
isDefault: updateData.isDefault ?? false,
updatedAt: new Date(),
})
.where(

View File

@@ -77,7 +77,7 @@ export const emailRouter = createTRPCRouter({
// Create email content
const subject =
input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
`Invoice ${invoice.invoiceNumber} from ${invoice.business ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` : "Your Business"}`;
const userName =
invoice.business?.emailFromName ??
@@ -124,7 +124,11 @@ export const emailRouter = createTRPCRouter({
// Use business's custom Resend setup
resendInstance = new Resend(invoice.business.resendApiKey);
const fromName =
invoice.business.emailFromName ?? invoice.business.name ?? userName;
invoice.business.emailFromName ??
(invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name) ??
userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) {
// Use system Resend configuration

View File

@@ -25,6 +25,7 @@ const ClientBackupSchema = z.object({
const BusinessBackupSchema = z.object({
name: z.string(),
nickname: z.string().optional(),
email: z.string().optional(),
phone: z.string().optional(),
addressLine1: z.string().optional(),
@@ -51,6 +52,7 @@ const InvoiceItemBackupSchema = z.object({
const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(),
businessName: z.string().optional(),
businessNickname: z.string().optional(),
clientName: z.string(),
issueDate: z.string().transform((str) => new Date(str)),
dueDate: z.string().transform((str) => new Date(str)),
@@ -205,6 +207,7 @@ export const settingsRouter = createTRPCRouter({
columns: {
id: true,
name: true,
nickname: true,
email: true,
phone: true,
addressLine1: true,
@@ -232,6 +235,7 @@ export const settingsRouter = createTRPCRouter({
business: {
columns: {
name: true,
nickname: true,
},
},
items: {
@@ -269,6 +273,7 @@ export const settingsRouter = createTRPCRouter({
})),
businesses: userBusinesses.map((business) => ({
name: business.name,
nickname: business.nickname ?? undefined,
email: business.email ?? undefined,
phone: business.phone ?? undefined,
addressLine1: business.addressLine1 ?? undefined,
@@ -285,6 +290,7 @@ export const settingsRouter = createTRPCRouter({
invoices: userInvoices.map((invoice) => ({
invoiceNumber: invoice.invoiceNumber,
businessName: invoice.business?.name,
businessNickname: invoice.business?.nickname,
clientName: invoice.client.name,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
@@ -337,6 +343,9 @@ export const settingsRouter = createTRPCRouter({
if (newBusiness) {
businessIdMap.set(businessData.name, newBusiness.id);
if (businessData.nickname) {
businessIdMap.set(businessData.nickname, newBusiness.id);
}
}
}
@@ -347,9 +356,14 @@ export const settingsRouter = createTRPCRouter({
throw new Error(`Client ${invoiceData.clientName} not found`);
}
const businessId = invoiceData.businessName
? businessIdMap.get(invoiceData.businessName)
: null;
const businessId = invoiceData.businessNickname
? (businessIdMap.get(invoiceData.businessNickname) ??
(invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null))
: invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null;
const [newInvoice] = await tx
.insert(invoices)

View File

@@ -143,6 +143,7 @@ export const businesses = createTable(
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.varchar({ length: 255 }).notNull(),
nickname: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }),
@@ -172,6 +173,7 @@ export const businesses = createTable(
(t) => [
index("business_created_by_idx").on(t.createdById),
index("business_name_idx").on(t.name),
index("business_nickname_idx").on(t.nickname),
index("business_email_idx").on(t.email),
index("business_is_default_idx").on(t.isDefault),
],

View File

@@ -26,6 +26,7 @@ export interface InvoiceWithRelations extends Invoice {
business: {
id: string;
name: string;
nickname: string | null;
email: string | null;
} | null;
invoiceItems: Array<{