mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
Update date picker, mobile styling
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -33,6 +33,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chrono-node": "^2.8.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -641,6 +642,8 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"chrono-node": ["chrono-node@2.8.3", "", { "dependencies": { "dayjs": "^1.10.0" } }, "sha512-YukiXak31pshonVWaeJ9cZ4xxWIlbsyn5qYUkG5pQ+usZ6l22ASXDIk0kHUQkIBNOCLRevFkHJjnGKXwZNtyZw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
@@ -683,6 +686,8 @@
|
||||
|
||||
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
1167
package-lock.json
generated
1167
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chrono-node": "^2.8.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@@ -49,35 +49,29 @@ function RegisterForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="auth-container">
|
||||
<div className="auth-form-container">
|
||||
{/* Logo and Welcome */}
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="auth-header">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-foreground text-2xl font-bold">
|
||||
Join beenvoice
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Create your account to get started
|
||||
</p>
|
||||
<h1 className="auth-title">Join beenvoice</h1>
|
||||
<p className="auth-subtitle">Create your account to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<Card className="border-0 shadow-xl">
|
||||
<Card className="auth-card">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-center text-xl">
|
||||
Create Account
|
||||
</CardTitle>
|
||||
<CardTitle className="auth-card-title">Create Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<form onSubmit={handleRegister} className="auth-form">
|
||||
<div className="auth-input-grid">
|
||||
<div className="auth-input-group">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<div className="relative">
|
||||
<User className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<User className="auth-input-icon" />
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
@@ -85,46 +79,46 @@ function RegisterForm() {
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="pl-10"
|
||||
className="form-input-with-icon"
|
||||
placeholder="First name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="auth-input-group">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<div className="relative">
|
||||
<User className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<User className="auth-input-icon" />
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
className="form-input-with-icon"
|
||||
placeholder="Last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="auth-input-group">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Mail className="auth-input-icon" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
className="form-input-with-icon"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="auth-input-group">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Lock className="auth-input-icon" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -132,15 +126,19 @@ function RegisterForm() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="pl-10"
|
||||
className="form-input-with-icon"
|
||||
placeholder="Create a password"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="auth-password-help">
|
||||
Must be at least 6 characters
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="auth-submit-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
"Creating account..."
|
||||
) : (
|
||||
@@ -151,14 +149,11 @@ function RegisterForm() {
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<div className="auth-footer-text">
|
||||
<span className="text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="font-medium text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
<Link href="/auth/signin" className="auth-footer-link">
|
||||
Sign in here
|
||||
</Link>
|
||||
</div>
|
||||
@@ -166,11 +161,9 @@ function RegisterForm() {
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Start invoicing like a pro
|
||||
</p>
|
||||
<div className="text-muted-foreground flex justify-center space-x-6 text-xs">
|
||||
<div className="auth-features">
|
||||
<p className="welcome-description">Start invoicing like a pro</p>
|
||||
<div className="auth-features-list">
|
||||
<span>✓ Free to start</span>
|
||||
<span>✓ No credit card</span>
|
||||
<span>✓ Cancel anytime</span>
|
||||
@@ -185,15 +178,13 @@ export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="auth-container">
|
||||
<div className="auth-form-container">
|
||||
<div className="auth-header">
|
||||
<Logo size="lg" className="mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-foreground text-2xl font-bold">
|
||||
Join beenvoice
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">Loading...</p>
|
||||
<h1 className="auth-title">Join beenvoice</h1>
|
||||
<p className="auth-subtitle">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ function SignInForm() {
|
||||
</div>
|
||||
|
||||
{/* Sign In Form */}
|
||||
<Card className="border-0 shadow-xl">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-center text-xl">Sign In</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -65,7 +65,7 @@ function SignInForm() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Mail className="form-icon-left" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -73,7 +73,7 @@ function SignInForm() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="pl-10"
|
||||
className="form-input-with-icon"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
@@ -81,14 +81,14 @@ function SignInForm() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Lock className="form-icon-left" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
className="form-input-with-icon"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
@@ -108,10 +108,7 @@ function SignInForm() {
|
||||
<span className="text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="font-medium text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
<Link href="/auth/register" className="nav-link-brand">
|
||||
Create one now
|
||||
</Link>
|
||||
</div>
|
||||
@@ -120,10 +117,10 @@ function SignInForm() {
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="welcome-description">
|
||||
Simple invoicing for freelancers and small businesses
|
||||
</p>
|
||||
<div className="text-muted-foreground flex justify-center space-x-6 text-xs">
|
||||
<div className="welcome-feature-list">
|
||||
<span>✓ Easy client management</span>
|
||||
<span>✓ Professional invoices</span>
|
||||
<span>✓ Payment tracking</span>
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function BusinessDetailPage({
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||
<Button variant="brand">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Business
|
||||
</Button>
|
||||
@@ -59,9 +59,9 @@ export default async function BusinessDetailPage({
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Business Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||
<CardTitle className="card-title-success">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Business Information</span>
|
||||
</CardTitle>
|
||||
@@ -71,50 +71,42 @@ export default async function BusinessDetailPage({
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{business.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Mail className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<Mail className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.email}
|
||||
</p>
|
||||
<p className="text-muted text-sm font-medium">Email</p>
|
||||
<p className="text-accent text-sm">{business.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Phone className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<Phone className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.phone}
|
||||
</p>
|
||||
<p className="text-muted text-sm font-medium">Phone</p>
|
||||
<p className="text-accent text-sm">{business.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business.website && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Globe className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<Globe className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
<p className="text-muted text-sm font-medium">
|
||||
Website
|
||||
</p>
|
||||
<a
|
||||
href={business.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground text-sm hover:text-emerald-600 hover:underline"
|
||||
className="link-primary text-sm"
|
||||
>
|
||||
{business.website}
|
||||
</a>
|
||||
@@ -124,16 +116,12 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{business.taxId && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Hash className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<Hash className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Tax ID
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{business.taxId}
|
||||
</p>
|
||||
<p className="text-muted text-sm font-medium">Tax ID</p>
|
||||
<p className="text-accent text-sm">{business.taxId}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -143,19 +131,21 @@ export default async function BusinessDetailPage({
|
||||
{(business.addressLine1 ?? business.city ?? business.state) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<MapPin className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
<p className="text-muted text-sm font-medium">
|
||||
Address
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||
<div className="text-accent ml-11 space-y-1 text-sm">
|
||||
{business.addressLine1 && <p>{business.addressLine1}</p>}
|
||||
{business.addressLine2 && <p>{business.addressLine2}</p>}
|
||||
{(business.city ?? business.state ?? business.postalCode) && (
|
||||
{(business.city ??
|
||||
business.state ??
|
||||
business.postalCode) && (
|
||||
<p>
|
||||
{[business.city, business.state, business.postalCode]
|
||||
.filter(Boolean)
|
||||
@@ -169,14 +159,14 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{/* Business Since */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Calendar className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<Calendar className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted text-sm font-medium">
|
||||
Business Added
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
<p className="text-secondary text-sm">
|
||||
{formatDate(business.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -185,16 +175,12 @@ export default async function BusinessDetailPage({
|
||||
{/* Default Business Badge */}
|
||||
{business.isDefault && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Building className="h-4 w-4 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<Building className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</p>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||
Default Business
|
||||
</Badge>
|
||||
<p className="text-muted text-sm font-medium">Status</p>
|
||||
<Badge className="badge-success">Default Business</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -204,23 +190,19 @@ export default async function BusinessDetailPage({
|
||||
|
||||
{/* Settings & Actions Card */}
|
||||
<div className="space-y-6">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-secondary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
||||
<CardTitle className="card-title-primary">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Business Settings</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Default Business
|
||||
</p>
|
||||
<p className="text-muted text-sm">Default Business</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{business.isDefault ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||
Yes
|
||||
</Badge>
|
||||
<Badge className="badge-success">Yes</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">No</Badge>
|
||||
)}
|
||||
@@ -228,7 +210,7 @@ export default async function BusinessDetailPage({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted text-sm font-medium">
|
||||
Quick Actions
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
@@ -258,20 +240,20 @@ export default async function BusinessDetailPage({
|
||||
</Card>
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-secondary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg dark:text-white">
|
||||
<CardTitle className="text-accent text-lg">
|
||||
About This Business
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="text-secondary space-y-3 text-sm">
|
||||
<p>
|
||||
This business profile is used for generating invoices and
|
||||
represents your company information to clients.
|
||||
</p>
|
||||
{business.isDefault && (
|
||||
<p className="text-emerald-600 dark:text-emerald-400">
|
||||
<p className="text-icon-emerald">
|
||||
This is your default business and will be automatically
|
||||
selected when creating new invoices.
|
||||
</p>
|
||||
|
||||
@@ -72,9 +72,9 @@ export default async function ClientDetailPage({
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Client Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||
<CardTitle className="client-section-title">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Contact Information</span>
|
||||
</CardTitle>
|
||||
@@ -83,33 +83,25 @@ export default async function ClientDetailPage({
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{client.email && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Mail className="h-4 w-4 text-emerald-600" />
|
||||
<div className="client-info-item">
|
||||
<div className="client-info-icon">
|
||||
<Mail className="client-info-icon-emerald" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{client.email}
|
||||
</p>
|
||||
<p className="client-info-label">Email</p>
|
||||
<p className="client-info-value">{client.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client.phone && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Phone className="h-4 w-4 text-emerald-600" />
|
||||
<div className="client-info-item">
|
||||
<div className="client-info-icon">
|
||||
<Phone className="client-info-icon-emerald" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-foreground text-sm">
|
||||
{client.phone}
|
||||
</p>
|
||||
<p className="client-info-label">Phone</p>
|
||||
<p className="client-info-value">{client.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -118,17 +110,15 @@ export default async function ClientDetailPage({
|
||||
{/* Address */}
|
||||
{(client.addressLine1 ?? client.city ?? client.state) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||
<div className="client-info-item">
|
||||
<div className="client-info-icon">
|
||||
<MapPin className="client-info-icon-emerald" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Address
|
||||
</p>
|
||||
<p className="client-info-label">Address</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||
<div className="client-address-content">
|
||||
{client.addressLine1 && <p>{client.addressLine1}</p>}
|
||||
{client.addressLine2 && <p>{client.addressLine2}</p>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
@@ -144,15 +134,13 @@ export default async function ClientDetailPage({
|
||||
)}
|
||||
|
||||
{/* Client Since */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<Calendar className="h-4 w-4 text-emerald-600" />
|
||||
<div className="client-info-item">
|
||||
<div className="client-info-icon">
|
||||
<Calendar className="client-info-icon-emerald" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Client Since
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
<p className="client-info-label">Client Since</p>
|
||||
<p className="client-info-value">
|
||||
{formatDate(client.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -163,39 +151,31 @@ export default async function ClientDetailPage({
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className="space-y-6">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
||||
<CardTitle className="client-stats-title">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
<span>Invoice Summary</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
<p className="client-total-amount">
|
||||
{formatCurrency(totalInvoiced)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Total Invoiced
|
||||
</p>
|
||||
<p className="client-total-label">Total Invoiced</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="client-stats-grid">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
{paidInvoices}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Paid
|
||||
</p>
|
||||
<p className="client-stat-value-paid">{paidInvoices}</p>
|
||||
<p className="client-stat-label">Paid</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-orange-600">
|
||||
<p className="client-stat-value-pending">
|
||||
{pendingInvoices}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Pending
|
||||
</p>
|
||||
<p className="client-stat-label">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -203,7 +183,7 @@ export default async function ClientDetailPage({
|
||||
|
||||
{/* Recent Invoices */}
|
||||
{client.invoices && client.invoices.length > 0 && (
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg dark:text-white">
|
||||
Recent Invoices
|
||||
@@ -212,20 +192,17 @@ export default async function ClientDetailPage({
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{client.invoices.slice(0, 3).map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-700"
|
||||
>
|
||||
<div key={invoice.id} className="invoice-item">
|
||||
<div>
|
||||
<p className="text-sm font-medium dark:text-white">
|
||||
<p className="invoice-item-title">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="invoice-item-date">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium dark:text-white">
|
||||
<p className="invoice-item-amount">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
<Badge
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Copy,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoiceActionsDropdownProps {
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
export function InvoiceActionsDropdown({ invoiceId }: InvoiceActionsDropdownProps) {
|
||||
const handleSendClick = () => {
|
||||
const sendButton = document.querySelector(
|
||||
"[data-testid='send-invoice-button']",
|
||||
) as HTMLButtonElement;
|
||||
sendButton?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSendClick}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send to Client
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ const columns: ColumnDef<InvoiceItem>[] = [
|
||||
accessorKey: "amount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-medium text-emerald-600">
|
||||
<div className="text-icon-emerald text-right font-medium">
|
||||
{formatCurrency(row.getValue("amount"))}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -85,7 +85,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
@@ -120,7 +120,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5 shadow-sm">
|
||||
<Card className="border-destructive/20 bg-destructive/5 card-secondary">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-destructive flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
@@ -143,7 +143,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Information */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
@@ -215,7 +215,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Business Information */}
|
||||
{invoice.business && (
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
@@ -258,7 +258,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
@@ -267,31 +267,34 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item) => (
|
||||
<div key={item.id} className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground mb-2 text-base font-medium">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="text-muted-foreground space-y-1 text-sm sm:space-y-0">
|
||||
<span className="sm:inline">
|
||||
{formatDate(item.date)}
|
||||
</span>
|
||||
<span className="block sm:inline sm:before:content-['_•_']">
|
||||
{item.hours} hours
|
||||
</span>
|
||||
<span className="block sm:inline sm:before:content-['_•_']">
|
||||
@ ${item.rate}/hr
|
||||
</span>
|
||||
<Card key={item.id} className="card-secondary">
|
||||
<CardContent className="py-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground mb-2 text-base font-medium">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<span className="inline whitespace-nowrap">
|
||||
{formatDate(item.date).replace(/ /g, "\u00A0")}
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
{item.hours.toString().replace(/ /g, "\u00A0")}
|
||||
hours
|
||||
</span>
|
||||
<span className="inline whitespace-nowrap before:mx-2 before:content-['_|_']">
|
||||
@ ${item.rate}/hr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-primary text-lg font-semibold">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-primary text-lg font-semibold">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
@@ -327,7 +330,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -342,7 +345,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card className="sticky top-6 shadow-sm">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
|
||||
@@ -64,7 +64,7 @@ function ImportStats() {
|
||||
return (
|
||||
<Card
|
||||
key={stat.title}
|
||||
className="border-0 shadow-md transition-shadow hover:shadow-lg"
|
||||
className="card-primary transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -92,19 +92,19 @@ function ImportStats() {
|
||||
// File Upload Component
|
||||
function FileUploadArea() {
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Upload className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Upload className="text-icon-emerald h-5 w-5" />
|
||||
Upload CSV File
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-8">
|
||||
<div className="mx-auto max-w-xl">
|
||||
{/* Drop Zone */}
|
||||
<div className="rounded-lg border-2 border-dashed border-emerald-300 bg-emerald-50/50 p-12 text-center transition-colors hover:border-emerald-400 hover:bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/10 dark:hover:bg-emerald-900/20">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<Upload className="h-8 w-8 text-emerald-600" />
|
||||
<div className="bg-upload-zone">
|
||||
<div className="bg-brand-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Upload className="text-icon-emerald h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Drop your CSV file here
|
||||
@@ -112,10 +112,7 @@ function FileUploadArea() {
|
||||
<p className="text-muted-foreground mb-4">
|
||||
or click to browse and select a file
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
|
||||
>
|
||||
<Button type="button" className="btn-brand-primary">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Choose File
|
||||
</Button>
|
||||
@@ -128,11 +125,11 @@ function FileUploadArea() {
|
||||
<div className="mt-6 hidden">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Uploading...</span>
|
||||
<span className="text-sm text-emerald-600">75%</span>
|
||||
<span className="text-icon-emerald text-sm">75%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div className="bg-progress-track">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 to-teal-600 transition-all duration-300"
|
||||
className="bg-brand-gradient h-full transition-all duration-300"
|
||||
style={{ width: "75%" }}
|
||||
></div>
|
||||
</div>
|
||||
@@ -148,16 +145,16 @@ function FormatInstructions() {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Required Format */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
<CardTitle className="card-title-info">
|
||||
<FileText className="text-icon-blue h-5 w-5" />
|
||||
Required CSV Format
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
|
||||
<p className="font-mono text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="bg-muted-subtle rounded-lg p-4">
|
||||
<p className="text-secondary font-mono text-sm">
|
||||
client_name,client_email,invoice_number,issue_date,due_date,description,hours,rate,tax_rate
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,9 +173,7 @@ function FormatInstructions() {
|
||||
{ field: "rate", desc: "Hourly rate (decimal)" },
|
||||
].map((col) => (
|
||||
<div key={col.field} className="flex items-start gap-3">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{col.field}
|
||||
</Badge>
|
||||
<Badge className="badge-outline text-xs">{col.field}</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{col.desc}
|
||||
</span>
|
||||
@@ -190,25 +185,19 @@ function FormatInstructions() {
|
||||
<div className="pt-2">
|
||||
<h4 className="mb-2 font-semibold">Optional Columns:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
tax_rate
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
notes
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
client_phone
|
||||
</Badge>
|
||||
<Badge className="badge-secondary text-xs">tax_rate</Badge>
|
||||
<Badge className="badge-secondary text-xs">notes</Badge>
|
||||
<Badge className="badge-secondary text-xs">client_phone</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sample Data & Download */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Download className="h-5 w-5 text-green-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Download className="text-icon-green h-5 w-5" />
|
||||
Sample Template
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -218,14 +207,12 @@ function FormatInstructions() {
|
||||
for importing invoices.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<div className="bg-green-subtle rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-5 w-5 text-green-600" />
|
||||
<Info className="text-icon-green mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-400">
|
||||
Pro Tip
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
<p className="text-success text-sm font-medium">Pro Tip</p>
|
||||
<p className="text-success text-sm">
|
||||
The template includes sample data and formatting examples to
|
||||
help you get started quickly.
|
||||
</p>
|
||||
@@ -249,8 +236,8 @@ function FormatInstructions() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50">
|
||||
<p className="font-mono text-xs break-all text-gray-600 dark:text-gray-400">
|
||||
<div className="bg-muted-subtle rounded-lg p-3">
|
||||
<p className="text-muted font-mono text-xs break-all">
|
||||
"Acme
|
||||
Corp","john@acme.com","INV-001","2024-01-15","2024-02-14","Web
|
||||
development work","40","75.00","8.5"
|
||||
@@ -266,10 +253,10 @@ function FormatInstructions() {
|
||||
// Important Notes Section
|
||||
function ImportantNotes() {
|
||||
return (
|
||||
<Card className="border-0 border-l-4 border-l-amber-500 shadow-lg">
|
||||
<Card className="card-primary border-l-4 border-l-amber-500">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
<CardTitle className="card-title-warning">
|
||||
<AlertCircle className="text-icon-amber h-5 w-5" />
|
||||
Important Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -331,7 +318,7 @@ function ImportHistory() {
|
||||
const getStatusBadge = (status: string) => {
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
<Badge className="badge-success">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
@@ -339,7 +326,7 @@ function ImportHistory() {
|
||||
}
|
||||
if (status === "processing") {
|
||||
return (
|
||||
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<Badge className="badge-features">
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Processing
|
||||
</Badge>
|
||||
@@ -354,10 +341,10 @@ function ImportHistory() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-purple-600" />
|
||||
<CardTitle className="card-title-purple">
|
||||
<FileText className="text-icon-purple h-5 w-5" />
|
||||
Recent Imports
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -382,8 +369,8 @@ function ImportHistory() {
|
||||
>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||
<FileSpreadsheet className="h-4 w-4 text-purple-600" />
|
||||
<div className="icon-bg-purple-muted">
|
||||
<FileSpreadsheet className="text-icon-purple h-4 w-4" />
|
||||
</div>
|
||||
<span className="font-medium">{item.filename}</span>
|
||||
</div>
|
||||
@@ -397,7 +384,7 @@ function ImportHistory() {
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
{item.errors > 0 ? (
|
||||
<span className="text-red-600">{item.errors}</span>
|
||||
<span className="status-text-error">{item.errors}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">0</span>
|
||||
)}
|
||||
@@ -443,7 +430,7 @@ export default async function ImportPage() {
|
||||
fallback={
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="border-0 shadow-md">
|
||||
<Card key={i} className="card-primary">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||
|
||||
@@ -94,7 +94,7 @@ function InvoiceItemCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border/50 border p-3 shadow-sm">
|
||||
<Card className="card-secondary">
|
||||
<div className="space-y-3">
|
||||
{/* Header with item number and delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -106,7 +106,7 @@ function InvoiceItemCard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
className="text-icon-red hover:text-error h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -123,7 +123,7 @@ function InvoiceItemCard({
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(index)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
@@ -142,7 +142,7 @@ function InvoiceItemCard({
|
||||
/>
|
||||
|
||||
{/* Date, Hours, Rate, Amount in compact grid */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
@@ -150,7 +150,8 @@ function InvoiceItemCard({
|
||||
onDateChange={(date) =>
|
||||
handleFieldChange("date", date ?? new Date())
|
||||
}
|
||||
className="[&>button]:h-8 [&>button]:text-xs"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -161,7 +162,6 @@ function InvoiceItemCard({
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -173,13 +173,12 @@ function InvoiceItemCard({
|
||||
step={0.25}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Amount</Label>
|
||||
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
||||
<span className="font-mono text-xs font-medium text-emerald-600">
|
||||
<span className="amount-primary">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -377,9 +376,9 @@ export default function NewInvoicePage() {
|
||||
description="Loading form data..."
|
||||
variant="gradient"
|
||||
/>
|
||||
<Card className="shadow-xl">
|
||||
<Card className="card-primary">
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||
<Loader2 className="text-icon-emerald h-8 w-8 animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -394,25 +393,25 @@ export default function NewInvoicePage() {
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" size="sm" className="w-full sm:w-auto">
|
||||
<Button variant="outline" size="sm" className="w-full md:w-auto">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back to Invoices</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
<span className="hidden md:inline">Back to Invoices</span>
|
||||
<span className="md:hidden">Back</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<FileText className="text-icon-emerald h-5 w-5" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Invoice Number</Label>
|
||||
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
||||
@@ -453,15 +452,15 @@ export default function NewInvoicePage() {
|
||||
</Card>
|
||||
|
||||
{/* Business & Client */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<Building className="text-icon-emerald h-5 w-5" />
|
||||
Business & Client
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">From Business</Label>
|
||||
<div className="relative">
|
||||
@@ -484,7 +483,7 @@ export default function NewInvoicePage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{business.name}</span>
|
||||
{business.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge className="badge-secondary text-xs">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
@@ -495,11 +494,11 @@ export default function NewInvoicePage() {
|
||||
</Select>
|
||||
</div>
|
||||
{(!businesses || businesses.length === 0) && (
|
||||
<p className="text-sm text-red-600">
|
||||
<p className="text-icon-red text-sm">
|
||||
No businesses found.{" "}
|
||||
<Link
|
||||
href="/dashboard/businesses/new"
|
||||
className="underline hover:text-red-700"
|
||||
className="link-secondary"
|
||||
>
|
||||
Create one first
|
||||
</Link>
|
||||
@@ -551,7 +550,7 @@ export default function NewInvoicePage() {
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -565,8 +564,8 @@ export default function NewInvoicePage() {
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Add Item</span>
|
||||
<Plus className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Add Item</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -585,7 +584,7 @@ export default function NewInvoicePage() {
|
||||
</Card>
|
||||
|
||||
{/* Tax & Totals */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||
@@ -595,22 +594,25 @@ export default function NewInvoicePage() {
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
taxRate: value,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
suffix="%"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2 md:col-span-1">
|
||||
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||
<NumberInput
|
||||
value={formData.taxRate}
|
||||
onChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
taxRate: value,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
suffix="%"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -659,21 +661,21 @@ export default function NewInvoicePage() {
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
ref={footerRef}
|
||||
className="flex flex-col gap-3 border-t pt-6 sm:flex-row sm:justify-between"
|
||||
className="flex flex-col gap-3 border-t pt-6 md:flex-row md:justify-between"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
<Button variant="outline" className="w-full md:w-auto">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<Button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -685,7 +687,7 @@ export default function NewInvoicePage() {
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 sm:w-auto"
|
||||
className="btn-brand-primary w-full md:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -708,8 +710,8 @@ export default function NewInvoicePage() {
|
||||
className="border-border/40 hover:bg-accent/50"
|
||||
size="sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Cancel</span>
|
||||
<ArrowLeft className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Cancel</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
@@ -720,24 +722,24 @@ export default function NewInvoicePage() {
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 sm:mr-2" />
|
||||
<Save className="h-4 w-4 md:mr-2" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Save Draft</span>
|
||||
<span className="hidden md:inline">Save Draft</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={isLoading || !isFormValid()}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
className="btn-brand-primary shadow-md"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
|
||||
) : (
|
||||
<Send className="h-4 w-4 sm:mr-2" />
|
||||
<Send className="h-4 w-4 md:mr-2" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Create Invoice</span>
|
||||
<span className="hidden md:inline">Create Invoice</span>
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
|
||||
@@ -28,10 +28,7 @@ export default async function InvoicesPage() {
|
||||
<span>Import CSV</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
>
|
||||
<Button asChild className="btn-brand-primary shadow-md">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Create Invoice</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-cosmic-gradient bg-nebula-overlay relative min-h-screen">
|
||||
<div className="floating-orbs relative min-h-screen">
|
||||
<Navbar />
|
||||
<Sidebar />
|
||||
{/* Mobile layout - no left margin */}
|
||||
|
||||
@@ -42,50 +42,46 @@ async function DashboardStats() {
|
||||
title: "Total Clients",
|
||||
value: totalClients.toString(),
|
||||
icon: Users,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-100 dark:bg-blue-900/20",
|
||||
color: "text-icon-blue",
|
||||
bgColor: "bg-brand-muted-blue",
|
||||
},
|
||||
{
|
||||
title: "Total Invoices",
|
||||
value: totalInvoices.toString(),
|
||||
icon: FileText,
|
||||
color: "text-emerald-600 dark:text-emerald-400",
|
||||
bgColor: "bg-emerald-100 dark:bg-emerald-900/20",
|
||||
color: "text-icon-emerald",
|
||||
bgColor: "bg-brand-muted",
|
||||
},
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: `$${totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2 })}`,
|
||||
icon: DollarSign,
|
||||
color: "text-teal-600 dark:text-teal-400",
|
||||
bgColor: "bg-teal-100 dark:bg-teal-900/20",
|
||||
color: "text-icon-teal",
|
||||
bgColor: "bg-brand-muted-teal",
|
||||
},
|
||||
{
|
||||
title: "Pending Invoices",
|
||||
value: pendingInvoices.toString(),
|
||||
icon: Calendar,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-100 dark:bg-amber-900/20",
|
||||
color: "text-icon-amber",
|
||||
bgColor: "bg-brand-muted-amber",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="mb-4 border-0 shadow-sm">
|
||||
<Card className="card-primary mb-4">
|
||||
<CardContent className="p-4 py-0">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="stats-grid">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.title} className="flex items-center space-x-3">
|
||||
<div className={`rounded-lg p-2 ${stat.bgColor}`}>
|
||||
<div key={stat.title} className="stats-item">
|
||||
<div className={`icon-bg-small ${stat.bgColor}`}>
|
||||
<Icon className={`h-4 w-4 ${stat.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className={`text-lg font-bold ${stat.color}`}>
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="stats-label">{stat.title}</p>
|
||||
<p className={`stats-value ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -99,38 +95,27 @@ async function DashboardStats() {
|
||||
// Quick Actions Component
|
||||
function QuickActions() {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card className="card-secondary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Plus className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="quick-action-title">
|
||||
<Plus className="quick-action-icon" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-sm hover:from-emerald-700 hover:to-teal-700"
|
||||
>
|
||||
<Button asChild className="btn-brand-primary w-full shadow-sm">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full border-0 shadow-sm"
|
||||
>
|
||||
<Button asChild variant="outline" className="w-full shadow-sm">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full border-0 shadow-sm"
|
||||
>
|
||||
<Button asChild variant="outline" className="w-full shadow-sm">
|
||||
<Link href="/dashboard/businesses/new">
|
||||
<TrendingUp className="mr-2 h-4 w-4" />
|
||||
Add Business
|
||||
@@ -153,7 +138,7 @@ async function RecentActivity() {
|
||||
|
||||
if (recentInvoices.length === 0) {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
@@ -161,15 +146,12 @@ async function RecentActivity() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center">
|
||||
<FileText className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground">
|
||||
<div className="recent-activity-empty">
|
||||
<FileText className="recent-activity-icon" />
|
||||
<p className="recent-activity-text">
|
||||
No invoices yet. Create your first invoice to get started!
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="mt-4 bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700"
|
||||
>
|
||||
<Button asChild className="btn-brand-primary mt-4">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
@@ -182,10 +164,10 @@ async function RecentActivity() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="text-muted-foreground h-5 w-5" />
|
||||
<Calendar className="text-muted h-5 w-5" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
@@ -196,34 +178,36 @@ async function RecentActivity() {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/20">
|
||||
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<Card key={invoice.id} className="card-secondary">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="activity-icon">
|
||||
<FileText className="text-icon-emerald h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-muted text-sm">
|
||||
{invoice.client?.name} • $
|
||||
{invoice.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{invoice.client?.name} • ${invoice.totalAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -262,11 +262,11 @@ export function SettingsContent() {
|
||||
{/* Profile & Account Overview */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Profile Section */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-emerald-100 p-2">
|
||||
<User className="h-5 w-5 text-emerald-600" />
|
||||
<div className="icon-bg-emerald">
|
||||
<User className="text-icon-emerald h-5 w-5" />
|
||||
</div>
|
||||
Profile Information
|
||||
</CardTitle>
|
||||
@@ -301,7 +301,7 @@ export function SettingsContent() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
|
||||
className="btn-brand-primary"
|
||||
>
|
||||
{updateProfileMutation.isPending
|
||||
? "Updating..."
|
||||
@@ -312,11 +312,11 @@ export function SettingsContent() {
|
||||
</Card>
|
||||
|
||||
{/* Data Overview */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-blue-100 p-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
<div className="icon-bg-info">
|
||||
<Database className="text-icon-blue h-5 w-5" />
|
||||
</div>
|
||||
Account Data
|
||||
</CardTitle>
|
||||
@@ -329,23 +329,24 @@ export function SettingsContent() {
|
||||
{dataStatItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2 ${item.bgColor}`}>
|
||||
<Icon className={`h-4 w-4 ${item.color}`} />
|
||||
<Card key={item.label} className="card-secondary">
|
||||
<CardContent className="py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2 ${item.bgColor}`}>
|
||||
<Icon className={`h-4 w-4 ${item.color}`} />
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{item.value}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{item.value}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -354,11 +355,11 @@ export function SettingsContent() {
|
||||
</div>
|
||||
|
||||
{/* Data Management */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-100 p-2">
|
||||
<Shield className="h-5 w-5 text-indigo-600" />
|
||||
<div className="bg-indigo-subtle rounded-lg p-2">
|
||||
<Shield className="text-icon-indigo h-5 w-5" />
|
||||
</div>
|
||||
Data Management
|
||||
</CardTitle>
|
||||
@@ -418,7 +419,7 @@ export function SettingsContent() {
|
||||
disabled={
|
||||
!importData.trim() || importDataMutation.isPending
|
||||
}
|
||||
className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700"
|
||||
className="btn-brand-primary"
|
||||
>
|
||||
{importDataMutation.isPending
|
||||
? "Importing..."
|
||||
@@ -444,11 +445,11 @@ export function SettingsContent() {
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="shadow-lg">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-red-100 p-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
<div className="icon-bg-error">
|
||||
<AlertTriangle className="text-icon-red h-5 w-5" />
|
||||
</div>
|
||||
Data Management
|
||||
</CardTitle>
|
||||
@@ -515,7 +516,7 @@ export function SettingsContent() {
|
||||
deleteConfirmText !== "delete all my data" ||
|
||||
deleteDataMutation.isPending
|
||||
}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
className="btn-danger"
|
||||
>
|
||||
{deleteDataMutation.isPending
|
||||
? "Deleting..."
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function RootLayout({
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" className={`${geist.variable} ${azeretMono.variable}`}>
|
||||
<body className="bg-gradient-dashboard bg-radial-overlay relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<body className="relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
201
src/app/page.tsx
201
src/app/page.tsx
@@ -20,25 +20,19 @@ import {
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-emerald-50 dark:from-slate-900 dark:via-slate-800 dark:to-emerald-900">
|
||||
<div className="bg-page-gradient min-h-screen">
|
||||
<AuthRedirect />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="sticky top-0 z-50 border-b bg-white/80 backdrop-blur-xl dark:border-slate-700 dark:bg-slate-900/80">
|
||||
<nav className="nav-sticky">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-14 items-center justify-between sm:h-16">
|
||||
<Logo />
|
||||
<div className="hidden items-center space-x-6 md:flex">
|
||||
<a
|
||||
href="#features"
|
||||
className="text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<a href="#features" className="nav-link">
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<a href="#pricing" className="nav-link">
|
||||
Pricing
|
||||
</a>
|
||||
</div>
|
||||
@@ -53,10 +47,7 @@ export default function HomePage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-lg shadow-emerald-500/25 transition-all duration-300 hover:shadow-xl hover:shadow-emerald-500/30"
|
||||
>
|
||||
<Button size="sm" className="btn-brand-primary">
|
||||
<span className="hidden sm:inline">Get Started Free</span>
|
||||
<span className="sm:hidden">Start Free</span>
|
||||
</Button>
|
||||
@@ -67,44 +58,35 @@ export default function HomePage() {
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-emerald-50 to-teal-50 px-4 pt-12 pb-16 sm:pt-20 dark:from-slate-800 dark:via-emerald-900/20 dark:to-teal-900/20">
|
||||
<section className="bg-hero-gradient relative overflow-hidden px-4 pt-12 pb-16 sm:pt-20">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-0 left-1/4 h-96 w-96 rounded-full bg-gradient-to-r from-emerald-400/20 to-blue-400/20 blur-3xl dark:from-emerald-500/10 dark:to-blue-500/10"></div>
|
||||
<div className="absolute top-32 right-1/4 h-80 w-80 rounded-full bg-gradient-to-r from-teal-400/20 to-emerald-400/20 blur-3xl dark:from-teal-500/10 dark:to-emerald-500/10"></div>
|
||||
<div className="absolute bottom-0 left-1/2 h-64 w-64 rounded-full bg-gradient-to-r from-blue-400/20 to-purple-400/20 blur-3xl dark:from-blue-500/10 dark:to-purple-500/10"></div>
|
||||
</div>
|
||||
<div className="hero-overlay"></div>
|
||||
<div className="hero-orb-1"></div>
|
||||
<div className="hero-orb-2"></div>
|
||||
<div className="hero-orb-3"></div>
|
||||
<div className="relative container mx-auto text-center">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mb-4 border-emerald-200 bg-emerald-100 text-emerald-800 sm:mb-6 dark:border-emerald-800 dark:bg-emerald-900 dark:text-emerald-200"
|
||||
>
|
||||
<Badge className="badge-brand mb-4 sm:mb-6">
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
100% Free Forever
|
||||
</Badge>
|
||||
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-slate-900 sm:mb-6 sm:text-6xl lg:text-7xl dark:text-slate-100">
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight text-white sm:mb-6 sm:text-6xl lg:text-7xl">
|
||||
Simple Invoicing for
|
||||
<span className="block bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
Freelancers
|
||||
</span>
|
||||
<span className="block text-emerald-100">Freelancers</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg leading-relaxed text-slate-600 sm:mb-8 sm:text-xl dark:text-slate-300">
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg leading-relaxed text-emerald-100 sm:mb-8 sm:text-xl">
|
||||
Create professional invoices, manage clients, and track payments.
|
||||
Built specifically for freelancers and small businesses—
|
||||
<span className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text font-semibold text-transparent">
|
||||
completely free
|
||||
</span>
|
||||
.
|
||||
<span className="font-semibold text-white">completely free</span>.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row sm:justify-center sm:gap-4">
|
||||
<div className="btn-group">
|
||||
<Link href="/auth/register">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group w-full bg-gradient-to-r from-emerald-500 to-teal-500 px-6 py-3 text-base font-semibold shadow-xl shadow-emerald-500/30 transition-all duration-300 hover:from-emerald-600 hover:to-teal-600 hover:shadow-2xl hover:shadow-emerald-500/40 sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
className="btn-brand-secondary group w-full px-6 py-3 text-base font-semibold text-emerald-700 shadow-xl transition-all duration-300 sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
>
|
||||
Start Free
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1 sm:h-5 sm:w-5" />
|
||||
@@ -114,7 +96,7 @@ export default function HomePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="group w-full border-emerald-200 bg-white/50 px-6 py-3 text-base text-emerald-700 backdrop-blur-sm hover:border-emerald-300 hover:bg-emerald-50 sm:w-auto sm:px-8 sm:py-4 sm:text-lg dark:border-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/40"
|
||||
className="btn-brand-secondary group w-full px-6 py-3 text-base sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
>
|
||||
See Features
|
||||
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1 sm:h-5 sm:w-5" />
|
||||
@@ -122,14 +104,14 @@ export default function HomePage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-2 text-sm text-slate-600 sm:mt-12 sm:flex-row sm:gap-6 dark:text-slate-400">
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-2 text-sm text-emerald-200 sm:mt-12 sm:flex-row sm:gap-6">
|
||||
{[
|
||||
"No credit card required",
|
||||
"Setup in 2 minutes",
|
||||
"Cancel anytime",
|
||||
].map((text, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<Check className="h-4 w-4 text-emerald-300" />
|
||||
<span className="text-center">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -141,20 +123,20 @@ export default function HomePage() {
|
||||
{/* Features Section */}
|
||||
<section
|
||||
id="features"
|
||||
className="bg-gradient-to-br from-white via-blue-50/30 to-emerald-50/50 py-16 sm:py-24 dark:from-slate-900 dark:via-slate-800/50 dark:to-emerald-900/20"
|
||||
className="bg-features-gradient relative overflow-hidden py-16 sm:py-24"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Floating background elements */}
|
||||
<div className="floating-decoration-1"></div>
|
||||
<div className="floating-decoration-2"></div>
|
||||
<div className="relative container mx-auto px-4">
|
||||
<div className="mb-12 text-center sm:mb-16">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mb-4 border-blue-200 bg-blue-100 text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
<Badge className="badge-features mb-4">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Supercharged Features
|
||||
</Badge>
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-slate-100">
|
||||
Everything you need to
|
||||
<span className="block bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
<span className="text-brand-gradient block">
|
||||
invoice professionally
|
||||
</span>
|
||||
</h2>
|
||||
@@ -166,9 +148,9 @@ export default function HomePage() {
|
||||
|
||||
<div className="grid gap-6 sm:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Feature 1 */}
|
||||
<Card className="group bg-white/70 shadow-lg backdrop-blur-sm transition-all duration-300 hover:bg-white/90 hover:shadow-xl dark:bg-slate-800/50 dark:hover:bg-slate-800/70">
|
||||
<Card className="card-floating group">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-emerald-500 to-teal-500 text-white shadow-lg">
|
||||
<div className="icon-bg-brand mb-4">
|
||||
<Rocket className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@@ -178,17 +160,17 @@ export default function HomePage() {
|
||||
Start creating invoices immediately. No complicated setup or
|
||||
configuration required.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<ul className="feature-list">
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Simple client management
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Professional templates
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Easy invoice sending
|
||||
</li>
|
||||
</ul>
|
||||
@@ -196,9 +178,9 @@ export default function HomePage() {
|
||||
</Card>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<Card className="group bg-white/70 shadow-lg backdrop-blur-sm transition-all duration-300 hover:bg-white/90 hover:shadow-xl dark:bg-slate-800/50 dark:hover:bg-slate-800/70">
|
||||
<Card className="card-floating group">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-indigo-500 text-white shadow-lg">
|
||||
<div className="icon-bg-blue mb-4">
|
||||
<BarChart3 className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@@ -208,17 +190,17 @@ export default function HomePage() {
|
||||
Keep track of invoice status and monitor which clients have
|
||||
paid.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<ul className="feature-list">
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Invoice status tracking
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Payment history
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Overdue notifications
|
||||
</li>
|
||||
</ul>
|
||||
@@ -226,9 +208,9 @@ export default function HomePage() {
|
||||
</Card>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<Card className="group bg-white/70 shadow-lg backdrop-blur-sm transition-all duration-300 hover:bg-white/90 hover:shadow-xl dark:bg-slate-800/50 dark:hover:bg-slate-800/70">
|
||||
<Card className="card-floating group">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg">
|
||||
<div className="icon-bg-purple mb-4">
|
||||
<Globe className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@@ -237,17 +219,17 @@ export default function HomePage() {
|
||||
<p className="mb-4 text-slate-600 dark:text-slate-300">
|
||||
Everything you need to look professional and get paid on time.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-slate-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<ul className="feature-list">
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
PDF generation
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Custom tax rates
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
<li className="feature-item">
|
||||
<Check className="feature-check" />
|
||||
Professional numbering
|
||||
</li>
|
||||
</ul>
|
||||
@@ -260,9 +242,12 @@ export default function HomePage() {
|
||||
{/* Pricing Section */}
|
||||
<section
|
||||
id="pricing"
|
||||
className="bg-gradient-to-br from-emerald-50 via-teal-50 to-blue-50 py-16 sm:py-24 dark:from-emerald-900/20 dark:via-teal-900/20 dark:to-blue-900/20"
|
||||
className="bg-features-gradient relative overflow-hidden py-16 sm:py-24"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Floating background elements */}
|
||||
<div className="floating-decoration-1"></div>
|
||||
<div className="floating-decoration-2"></div>
|
||||
<div className="relative container mx-auto px-4">
|
||||
<div className="mb-12 text-center sm:mb-16">
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-slate-100">
|
||||
Simple, transparent pricing
|
||||
@@ -276,13 +261,11 @@ export default function HomePage() {
|
||||
<div className="mx-auto max-w-md">
|
||||
<Card className="relative border-2 border-emerald-500 bg-white/90 shadow-2xl backdrop-blur-sm dark:border-emerald-400 dark:bg-slate-800/90">
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<Badge className="bg-emerald-500 px-6 py-1 text-white dark:bg-emerald-600">
|
||||
Forever Free
|
||||
</Badge>
|
||||
<Badge className="badge-success px-6 py-1">Forever Free</Badge>
|
||||
</div>
|
||||
<CardContent className="p-6 text-center sm:p-8">
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-5xl font-bold text-transparent sm:text-6xl">
|
||||
<div className="text-brand-gradient mb-2 text-5xl font-bold sm:text-6xl">
|
||||
$0
|
||||
</div>
|
||||
<div className="text-slate-600 dark:text-slate-400">
|
||||
@@ -311,7 +294,10 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<Link href="/auth/register">
|
||||
<Button className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 py-3 text-base font-semibold shadow-lg shadow-emerald-500/30 transition-all duration-300 hover:from-emerald-600 hover:to-teal-600 hover:shadow-xl hover:shadow-emerald-500/40 sm:text-lg">
|
||||
<Button
|
||||
variant="brand"
|
||||
className="w-full py-3 text-base font-semibold sm:text-lg"
|
||||
>
|
||||
Get Started Now
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -326,12 +312,15 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* Why Choose */}
|
||||
<section className="bg-gradient-to-br from-white via-emerald-50/30 to-teal-50/50 py-16 sm:py-24 dark:from-slate-900 dark:via-emerald-900/10 dark:to-teal-900/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<section className="bg-features-gradient relative overflow-hidden py-16 sm:py-24">
|
||||
{/* Floating background elements */}
|
||||
<div className="floating-decoration-1"></div>
|
||||
<div className="floating-decoration-2"></div>
|
||||
<div className="relative container mx-auto px-4">
|
||||
<div className="mb-12 text-center sm:mb-16">
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-slate-100">
|
||||
Why freelancers
|
||||
<span className="block bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
<span className="text-brand-gradient block">
|
||||
choose BeenVoice
|
||||
</span>
|
||||
</h2>
|
||||
@@ -339,7 +328,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="grid gap-6 sm:gap-8 md:grid-cols-3">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-emerald-500 to-teal-500 text-white shadow-lg">
|
||||
<div className="icon-bg-emerald mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl text-white shadow-lg">
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@@ -351,7 +340,7 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-indigo-500 text-white shadow-lg">
|
||||
<div className="icon-bg-blue mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl text-white shadow-lg">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@@ -363,7 +352,7 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg">
|
||||
<div className="icon-bg-purple mb-4">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-3 text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
@@ -379,11 +368,11 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-emerald-500 via-teal-600 to-blue-700 py-16 sm:py-24">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/95 via-teal-600/95 to-blue-700/95"></div>
|
||||
<div className="absolute top-10 left-10 h-64 w-64 rounded-full bg-gradient-to-r from-white/20 to-emerald-300/20 blur-3xl"></div>
|
||||
<div className="absolute right-10 bottom-10 h-80 w-80 rounded-full bg-gradient-to-r from-teal-300/15 to-blue-300/15 blur-3xl"></div>
|
||||
<div className="absolute top-1/2 left-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-emerald-400/10 to-teal-400/10 blur-3xl"></div>
|
||||
<section className="bg-hero-gradient relative overflow-hidden py-16 sm:py-24">
|
||||
<div className="hero-overlay"></div>
|
||||
<div className="hero-orb-1"></div>
|
||||
<div className="hero-orb-2"></div>
|
||||
<div className="hero-orb-3"></div>
|
||||
|
||||
<div className="relative container mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
@@ -402,7 +391,7 @@ export default function HomePage() {
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="group w-full bg-white px-6 py-3 text-base font-semibold text-emerald-700 shadow-xl transition-all duration-300 hover:bg-gradient-to-r hover:from-white hover:to-emerald-50 hover:shadow-2xl sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
className="btn-brand-secondary group w-full px-6 py-3 text-base font-semibold text-emerald-700 shadow-xl transition-all duration-300 sm:w-auto sm:px-8 sm:py-4 sm:text-lg"
|
||||
>
|
||||
Start Your Success Story
|
||||
<Rocket className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1 sm:h-5 sm:w-5" />
|
||||
@@ -429,41 +418,29 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-gradient-to-br from-slate-50 to-emerald-50/30 py-8 sm:py-12 dark:border-slate-700 dark:from-slate-900 dark:to-emerald-900/10">
|
||||
<footer className="bg-features-gradient border-t py-8 sm:py-12 dark:border-slate-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto mb-4" />
|
||||
<p className="mb-4 text-sm text-slate-600 sm:mb-6 sm:text-base dark:text-slate-400">
|
||||
<p className="text-muted mb-4 text-sm sm:mb-6 sm:text-base">
|
||||
Simple invoicing for freelancers. Free, forever.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-600 sm:gap-6 dark:text-slate-400">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
<div className="text-muted flex flex-wrap items-center justify-center gap-4 text-sm sm:gap-6">
|
||||
<Link href="/auth/signin" className="link-primary">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
Get Started
|
||||
<Link href="/auth/register" className="link-primary">
|
||||
Register
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
<a href="#features" className="link-primary">
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="transition-colors hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
<a href="#pricing" className="link-primary">
|
||||
Pricing
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-6 border-t pt-6 sm:mt-8 sm:pt-8">
|
||||
<p className="text-sm text-slate-600 sm:text-base dark:text-slate-400">
|
||||
<p className="text-muted text-sm sm:text-base">
|
||||
© 2024 BeenVoice. Built with ♥ for entrepreneurs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,12 @@ interface AddressAutocompleteProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function AddressAutocomplete({ value, onChange, onSelect, placeholder }: AddressAutocompleteProps) {
|
||||
export function AddressAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: AddressAutocompleteProps) {
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -20,7 +25,9 @@ export function AddressAutocomplete({ value, onChange, onSelect, placeholder }:
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`);
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
setSuggestions(data);
|
||||
};
|
||||
@@ -50,12 +57,12 @@ export function AddressAutocomplete({ value, onChange, onSelect, placeholder }:
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
/>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<Card className="absolute z-10 mt-1 w-full max-h-60 overflow-auto shadow-lg border bg-white">
|
||||
<Card className="card-primary absolute z-10 mt-1 max-h-60 w-full overflow-auto">
|
||||
<ul>
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={s.place_id}
|
||||
className="px-4 py-2 cursor-pointer hover:bg-muted text-sm"
|
||||
className="hover:bg-muted cursor-pointer px-4 py-2 text-sm"
|
||||
onMouseDown={() => handleSelect(s.display_name)}
|
||||
>
|
||||
{s.display_name}
|
||||
@@ -66,4 +73,4 @@ export function AddressAutocomplete({ value, onChange, onSelect, placeholder }:
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,9 +438,9 @@ export function CSVImportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Global Client Selection */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-800">
|
||||
<CardTitle className="card-title-primary">
|
||||
<Users className="h-5 w-5" />
|
||||
Default Client
|
||||
</CardTitle>
|
||||
@@ -460,7 +460,7 @@ export function CSVImportPage() {
|
||||
applyGlobalClient(newClientId);
|
||||
}
|
||||
}}
|
||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
className="h-12 w-full rounded-md border px-3 py-2"
|
||||
disabled={loadingClients}
|
||||
>
|
||||
<option value="">No default client (select individually)</option>
|
||||
@@ -470,7 +470,7 @@ export function CSVImportPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-muted text-xs">
|
||||
This client will be automatically selected for all uploaded files.
|
||||
You can still change individual files below.
|
||||
</p>
|
||||
@@ -479,9 +479,9 @@ export function CSVImportPage() {
|
||||
</Card>
|
||||
|
||||
{/* File Upload Area */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-800">
|
||||
<CardTitle className="card-title-primary">
|
||||
<Upload className="h-5 w-5" />
|
||||
Upload CSV Files
|
||||
</CardTitle>
|
||||
@@ -500,32 +500,32 @@ export function CSVImportPage() {
|
||||
{totalFiles > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg bg-emerald-50/50 p-4 md:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
<div className="text-icon-emerald text-2xl font-bold">
|
||||
{totalFiles}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Files</div>
|
||||
<div className="text-xs text-gray-500">of 50 max</div>
|
||||
<div className="text-secondary text-sm">Files</div>
|
||||
<div className="text-muted text-xs">of 50 max</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
<div className="text-icon-emerald text-2xl font-bold">
|
||||
{totalItems}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Total Items</div>
|
||||
<div className="text-secondary text-sm">Total Items</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
<div className="text-icon-emerald text-2xl font-bold">
|
||||
{totalAmount.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Total Amount</div>
|
||||
<div className="text-secondary text-sm">Total Amount</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
<div className="text-icon-emerald text-2xl font-bold">
|
||||
{readyFiles}/{totalFiles}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Ready</div>
|
||||
<div className="text-secondary text-sm">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -534,9 +534,9 @@ export function CSVImportPage() {
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-800">Uploaded Files</CardTitle>
|
||||
<CardTitle className="text-brand-light">Uploaded Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
@@ -547,12 +547,12 @@ export function CSVImportPage() {
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
<FileText className="text-icon-emerald h-5 w-5" />
|
||||
<div>
|
||||
<h3 className="truncate font-medium text-gray-900">
|
||||
<h3 className="text-accent truncate font-medium">
|
||||
{fileData.file.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-muted text-sm">
|
||||
{fileData.parsedItems.length} items •{" "}
|
||||
{fileData.parsedItems
|
||||
.reduce((sum, item) => sum + item.hours, 0)
|
||||
@@ -574,7 +574,7 @@ export function CSVImportPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeFile(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
className="text-icon-red hover:text-error"
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Remove
|
||||
@@ -584,7 +584,7 @@ export function CSVImportPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-secondary text-xs font-medium">
|
||||
Invoice Number
|
||||
</Label>
|
||||
<Input
|
||||
@@ -614,7 +614,7 @@ export function CSVImportPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-secondary text-xs font-medium">
|
||||
Issue Date
|
||||
</Label>
|
||||
<DatePicker
|
||||
@@ -628,7 +628,7 @@ export function CSVImportPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-secondary text-xs font-medium">
|
||||
Due Date
|
||||
</Label>
|
||||
<DatePicker
|
||||
@@ -646,18 +646,18 @@ export function CSVImportPage() {
|
||||
{fileData.errors.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
<AlertCircle className="text-icon-red h-4 w-4" />
|
||||
<span className="text-error text-sm font-medium">
|
||||
Issues Found
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm text-red-700">
|
||||
<ul className="text-error space-y-1 text-sm">
|
||||
{fileData.errors.map((error, errorIndex) => (
|
||||
<li
|
||||
key={errorIndex}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<span className="text-red-600">•</span>
|
||||
<span className="text-icon-red">•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -677,7 +677,7 @@ export function CSVImportPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileData.errors.length > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Badge className="badge-error text-xs">
|
||||
{fileData.errors.length} Error
|
||||
{fileData.errors.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
@@ -712,7 +712,7 @@ export function CSVImportPage() {
|
||||
|
||||
{/* Batch Actions */}
|
||||
{files.length > 0 && (
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isProcessing && (
|
||||
@@ -732,7 +732,7 @@ export function CSVImportPage() {
|
||||
<Button
|
||||
onClick={processBatch}
|
||||
disabled={readyFiles === 0 || isProcessing}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700"
|
||||
className="btn-brand-primary"
|
||||
>
|
||||
{isProcessing
|
||||
? "Processing..."
|
||||
@@ -746,7 +746,7 @@ export function CSVImportPage() {
|
||||
|
||||
{/* Preview Modal */}
|
||||
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col border-0 bg-white/95 shadow-2xl backdrop-blur-sm">
|
||||
<DialogContent className="card-primary flex max-h-[90vh] max-w-4xl flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl font-bold text-gray-800">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
@@ -834,7 +834,7 @@ export function CSVImportPage() {
|
||||
currency: "USD",
|
||||
})}
|
||||
</td>
|
||||
<td className="p-2 text-right font-medium whitespace-nowrap text-gray-600">
|
||||
<td className="text-secondary p-2 text-right font-medium whitespace-nowrap">
|
||||
{item.amount.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
|
||||
@@ -3,13 +3,35 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Mail, Phone, MapPin, Edit, Trash2, Eye, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
export function ClientList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -29,10 +51,12 @@ export function ClientList() {
|
||||
},
|
||||
});
|
||||
|
||||
const filteredClients = clients?.filter(client =>
|
||||
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
client.email?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) ?? [];
|
||||
const filteredClients =
|
||||
clients?.filter(
|
||||
(client) =>
|
||||
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
client.email?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
const handleDelete = (clientId: string) => {
|
||||
setClientToDelete(clientId);
|
||||
@@ -49,14 +73,14 @@ export function ClientList() {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(3)].map((_, i: number) => (
|
||||
<Card key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<Card key={i} className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 animate-pulse rounded bg-gray-200" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3 animate-pulse" />
|
||||
<div className="h-3 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -67,9 +91,9 @@ export function ClientList() {
|
||||
|
||||
if (!clients || clients.length === 0) {
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
<CardTitle className="text-brand-gradient text-2xl font-bold">
|
||||
No Clients Yet
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg">
|
||||
@@ -78,9 +102,7 @@ export function ClientList() {
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button
|
||||
className="w-full h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Button variant="brand" className="h-12 w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Client
|
||||
</Button>
|
||||
@@ -92,24 +114,24 @@ export function ClientList() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Label htmlFor="search" className="sr-only">Search clients</Label>
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Label htmlFor="search" className="sr-only">
|
||||
Search clients
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Search className="text-muted absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
className="h-12 pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button
|
||||
className="w-full sm:w-auto h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Button variant="brand" className="h-12 w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Button>
|
||||
@@ -118,20 +140,23 @@ export function ClientList() {
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredClients.map((client) => (
|
||||
<Card key={client.id} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300 group">
|
||||
<Card
|
||||
key={client.id}
|
||||
className="group card-primary transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between text-lg">
|
||||
<span className="font-semibold text-gray-800 group-hover:text-emerald-600 transition-colors">
|
||||
<span className="text-accent group-hover:text-icon-emerald font-semibold transition-colors">
|
||||
{client.name}
|
||||
</span>
|
||||
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex space-x-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Link href={`/clients/${client.id}`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -139,7 +164,7 @@ export function ClientList() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(client.id)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 hover:text-red-600"
|
||||
className="hover:bg-error-subtle hover:text-icon-red h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -148,32 +173,34 @@ export function ClientList() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{client.email && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="p-1.5 bg-emerald-100 rounded mr-3">
|
||||
<Mail className="h-3 w-3 text-emerald-600" />
|
||||
<div className="text-secondary flex items-center text-sm">
|
||||
<div className="bg-brand-muted mr-3 rounded p-1.5">
|
||||
<Mail className="text-icon-emerald h-3 w-3" />
|
||||
</div>
|
||||
{client.email}
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="p-1.5 bg-blue-100 rounded mr-3">
|
||||
<Phone className="h-3 w-3 text-blue-600" />
|
||||
<div className="text-secondary flex items-center text-sm">
|
||||
<div className="bg-brand-muted-blue mr-3 rounded p-1.5">
|
||||
<Phone className="text-icon-blue h-3 w-3" />
|
||||
</div>
|
||||
{client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(client.addressLine1 ?? client.city ?? client.state) && (
|
||||
<div className="flex items-start text-sm text-gray-600">
|
||||
<div className="p-1.5 bg-teal-100 rounded mr-3 mt-0.5 flex-shrink-0">
|
||||
<MapPin className="h-3 w-3 text-teal-600" />
|
||||
<div className="text-secondary flex items-start text-sm">
|
||||
<div className="bg-brand-muted-teal mt-0.5 mr-3 flex-shrink-0 rounded p-1.5">
|
||||
<MapPin className="text-icon-teal h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{client.addressLine1 && <div>{client.addressLine1}</div>}
|
||||
{client.addressLine2 && <div>{client.addressLine2}</div>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
<div>
|
||||
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
|
||||
{[client.city, client.state, client.postalCode]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{client.country && <div>{client.country}</div>}
|
||||
@@ -186,23 +213,26 @@ export function ClientList() {
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
|
||||
<DialogContent className="card-primary">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800">Delete Client</DialogTitle>
|
||||
<DialogDescription className="text-gray-600">
|
||||
Are you sure you want to delete this client? This action cannot be undone.
|
||||
<DialogTitle className="text-accent text-xl font-bold">
|
||||
Delete Client
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-secondary">
|
||||
Are you sure you want to delete this client? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
className="text-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
@@ -213,4 +243,4 @@ export function ClientList() {
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import {
|
||||
FileText,
|
||||
Clock,
|
||||
Plus,
|
||||
Edit,
|
||||
import {
|
||||
FileText,
|
||||
Clock,
|
||||
Plus,
|
||||
Edit,
|
||||
Eye,
|
||||
DollarSign,
|
||||
User,
|
||||
Calendar
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
|
||||
export function CurrentOpenInvoiceCard() {
|
||||
const { data: currentInvoice, isLoading } = api.invoices.getCurrentOpen.useQuery();
|
||||
const { data: currentInvoice, isLoading } =
|
||||
api.invoices.getCurrentOpen.useQuery();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
@@ -36,10 +37,10 @@ export function CurrentOpenInvoiceCard() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<FileText className="text-icon-emerald h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -57,20 +58,21 @@ export function CurrentOpenInvoiceCard() {
|
||||
|
||||
if (!currentInvoice) {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<FileText className="text-icon-emerald h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center py-6">
|
||||
<FileText className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
No open invoice found. Create a new invoice to start tracking your time.
|
||||
<div className="py-6 text-center">
|
||||
<FileText className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
No open invoice found. Create a new invoice to start tracking your
|
||||
time.
|
||||
</p>
|
||||
<Button asChild className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700">
|
||||
<Button asChild variant="brand">
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create New Invoice
|
||||
@@ -82,14 +84,15 @@ export function CurrentOpenInvoiceCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const totalHours = currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
const totalHours =
|
||||
currentInvoice.items?.reduce((sum, item) => sum + item.hours, 0) ?? 0;
|
||||
const totalAmount = currentInvoice.totalAmount;
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="h-5 w-5 text-emerald-600" />
|
||||
<CardTitle className="card-title-secondary">
|
||||
<FileText className="text-icon-emerald h-5 w-5" />
|
||||
Current Open Invoice
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -97,15 +100,13 @@ export function CurrentOpenInvoiceCard() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge className="badge-secondary text-xs">
|
||||
{currentInvoice.invoiceNumber}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Draft
|
||||
</Badge>
|
||||
<Badge className="badge-outline text-xs">Draft</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-emerald-600">
|
||||
<p className="text-icon-emerald text-sm font-medium">
|
||||
{formatCurrency(totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,19 +114,21 @@ export function CurrentOpenInvoiceCard() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
<User className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Client:</span>
|
||||
<span className="font-medium">{currentInvoice.client?.name}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||
<Calendar className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Due:</span>
|
||||
<span className="font-medium">{formatDate(currentInvoice.dueDate)}</span>
|
||||
<span className="font-medium">
|
||||
{formatDate(currentInvoice.dueDate)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<Clock className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Hours:</span>
|
||||
<span className="font-medium">{totalHours.toFixed(1)}h</span>
|
||||
</div>
|
||||
@@ -139,7 +142,7 @@ export function CurrentOpenInvoiceCard() {
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="flex-1 bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700">
|
||||
<Button asChild variant="brand" size="sm" className="flex-1">
|
||||
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-3 w-3" />
|
||||
Continue
|
||||
@@ -149,4 +152,4 @@ export function CurrentOpenInvoiceCard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +106,10 @@ export function DataTable<TData, TValue>({
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 640); // sm breakpoint
|
||||
};
|
||||
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
// Create responsive columns that properly hide on mobile
|
||||
@@ -118,9 +118,23 @@ export function DataTable<TData, TValue>({
|
||||
...column,
|
||||
// Add a meta property to control responsive visibility
|
||||
meta: {
|
||||
...((column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta ?? {}),
|
||||
headerClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.headerClassName ?? "",
|
||||
cellClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.cellClassName ?? "",
|
||||
...((
|
||||
column as ColumnDef<TData, TValue> & {
|
||||
meta?: { headerClassName?: string; cellClassName?: string };
|
||||
}
|
||||
).meta ?? {}),
|
||||
headerClassName:
|
||||
(
|
||||
column as ColumnDef<TData, TValue> & {
|
||||
meta?: { headerClassName?: string; cellClassName?: string };
|
||||
}
|
||||
).meta?.headerClassName ?? "",
|
||||
cellClassName:
|
||||
(
|
||||
column as ColumnDef<TData, TValue> & {
|
||||
meta?: { headerClassName?: string; cellClassName?: string };
|
||||
}
|
||||
).meta?.cellClassName ?? "",
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
@@ -163,15 +177,16 @@ export function DataTable<TData, TValue>({
|
||||
const handleRowClick = (row: TData, event: React.MouseEvent) => {
|
||||
// Don't trigger row click if clicking on action buttons or their children
|
||||
const target = event.target as HTMLElement;
|
||||
const isActionButton = target.closest('[data-action-button="true"]') ??
|
||||
target.closest('button') ??
|
||||
target.closest('a') ??
|
||||
target.closest('[role="button"]');
|
||||
|
||||
const isActionButton =
|
||||
target.closest('[data-action-button="true"]') ??
|
||||
target.closest("button") ??
|
||||
target.closest("a") ??
|
||||
target.closest('[role="button"]');
|
||||
|
||||
if (isActionButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
onRowClick?.(row);
|
||||
};
|
||||
|
||||
@@ -200,7 +215,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
{/* Filter Bar Card */}
|
||||
{(showSearch || filterableColumns.length > 0 || showColumnVisibility) && (
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<Card className="card-primary py-2">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSearch && (
|
||||
@@ -300,7 +315,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
|
||||
{/* Table Content Card */}
|
||||
<Card className="overflow-hidden border-0 p-0 shadow-sm">
|
||||
<Card className="card-primary overflow-hidden p-0">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -310,7 +325,9 @@ export function DataTable<TData, TValue>({
|
||||
className="bg-muted/50 hover:bg-muted/50"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
|
||||
const meta = header.column.columnDef.meta as
|
||||
| { headerClassName?: string; cellClassName?: string }
|
||||
| undefined;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -339,12 +356,16 @@ export function DataTable<TData, TValue>({
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(
|
||||
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors",
|
||||
onRowClick && "cursor-pointer"
|
||||
onRowClick && "cursor-pointer",
|
||||
)}
|
||||
onClick={(event) => onRowClick && handleRowClick(row.original, event)}
|
||||
onClick={(event) =>
|
||||
onRowClick && handleRowClick(row.original, event)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| { headerClassName?: string; cellClassName?: string }
|
||||
| undefined;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
@@ -379,7 +400,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
{/* Pagination Bar Card */}
|
||||
{showPagination && (
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<Card className="card-primary py-2">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -540,7 +561,7 @@ export function DataTableSkeleton({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar skeleton */}
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<Card className="card-primary py-2">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse rounded-md sm:max-w-sm"></div>
|
||||
@@ -550,7 +571,7 @@ export function DataTableSkeleton({
|
||||
</Card>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<Card className="overflow-hidden border-0 p-0 shadow-sm">
|
||||
<Card className="card-primary overflow-hidden p-0">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -562,16 +583,16 @@ export function DataTableSkeleton({
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:table-cell sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:table-cell sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
|
||||
<TableHead className="hidden h-12 px-3 text-left align-middle sm:h-14 sm:px-4 lg:table-cell">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -588,11 +609,11 @@ export function DataTableSkeleton({
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Status (sm+) */}
|
||||
<TableCell className="hidden sm:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:table-cell sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Amount (sm+) */}
|
||||
<TableCell className="hidden sm:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:table-cell sm:px-4 sm:py-4">
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
@@ -600,7 +621,7 @@ export function DataTableSkeleton({
|
||||
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
|
||||
</TableCell>
|
||||
{/* Extra (lg+) */}
|
||||
<TableCell className="hidden lg:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
|
||||
<TableCell className="hidden px-3 py-3 align-middle sm:px-4 sm:py-4 lg:table-cell">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -611,7 +632,7 @@ export function DataTableSkeleton({
|
||||
</Card>
|
||||
|
||||
{/* Pagination skeleton */}
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<Card className="card-primary py-2">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -21,14 +21,11 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trash2, GripVertical, CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -50,6 +47,10 @@ function SortableItem({
|
||||
index,
|
||||
onItemChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
@@ -59,6 +60,10 @@ function SortableItem({
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onMoveUp: (index: number) => void;
|
||||
onMoveDown: (index: number) => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -82,101 +87,193 @@ function SortableItem({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`grid grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4 transition-colors hover:border-emerald-300 dark:border-gray-700 dark:hover:border-emerald-500 ${
|
||||
className={`card-secondary rounded-lg transition-colors ${
|
||||
isDragging ? "opacity-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div className="col-span-1 flex h-10 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab rounded p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-400"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Desktop Layout - Hidden on Mobile */}
|
||||
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
|
||||
{/* Drag Handle */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-muted-foreground hover:bg-muted hover:text-foreground cursor-grab rounded p-2 transition-colors active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="col-span-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-between border-gray-200 text-sm font-normal focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
||||
>
|
||||
{item.date ? format(item.date, "MMM dd") : "Date"}
|
||||
<CalendarIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={item.date}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(selectedDate: Date | undefined) => {
|
||||
handleItemChange("date", selectedDate ?? new Date());
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{/* Date */}
|
||||
<div className="col-span-2">
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleItemChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Work description"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Work description"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
value={item.hours}
|
||||
onChange={(e) => handleItemChange("hours", e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{/* Hours */}
|
||||
<div className="col-span-1">
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleItemChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rate */}
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.rate}
|
||||
onChange={(e) => handleItemChange("rate", e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{/* Rate */}
|
||||
<div className="col-span-2">
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleItemChange("rate", value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="col-span-1">
|
||||
<div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
${item.amount.toFixed(2)}
|
||||
{/* Amount */}
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted/30 flex h-9 items-center rounded-md border px-3 font-medium text-emerald-600">
|
||||
${item.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-10 border-red-200 p-0 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Mobile Layout - Visible on Mobile Only */}
|
||||
<div className="space-y-4 p-4 md:hidden">
|
||||
{/* Header with Item Number and Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
Item {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(index)}
|
||||
disabled={!canMoveUp}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(index)}
|
||||
disabled={!canMoveDown}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Description</Label>
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Description of work..."
|
||||
className="min-h-[48px] resize-none text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
handleItemChange("date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => handleItemChange("hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
placeholder="0"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => handleItemChange("rate", value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="bg-muted/20 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Total Amount:</span>
|
||||
<span className="font-mono text-lg font-bold text-emerald-600">
|
||||
${item.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -244,6 +341,20 @@ export function EditableInvoiceItems({
|
||||
onItemsChange(newItems);
|
||||
};
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const newItems = arrayMove(items, index, index - 1);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index < items.length - 1) {
|
||||
const newItems = arrayMove(items, index, index + 1);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// Show skeleton loading on server-side
|
||||
if (!isClient) {
|
||||
return (
|
||||
@@ -251,28 +362,42 @@ export function EditableInvoiceItems({
|
||||
{items.map((item, _index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
|
||||
className="card-secondary animate-pulse rounded-lg p-4"
|
||||
>
|
||||
<div className="col-span-1 flex h-10 items-center justify-center">
|
||||
<div className="h-4 w-4 rounded bg-gray-300"></div>
|
||||
{/* Desktop Skeleton */}
|
||||
<div className="hidden grid-cols-12 gap-3 md:grid">
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-4 w-4 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="bg-muted h-9 w-9 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 w-10 rounded bg-gray-300"></div>
|
||||
{/* Mobile Skeleton */}
|
||||
<div className="space-y-3 md:hidden">
|
||||
<div className="bg-muted h-4 w-20 rounded"></div>
|
||||
<div className="bg-muted h-16 rounded"></div>
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
<div className="bg-muted h-9 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-muted h-12 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -281,27 +406,44 @@ export function EditableInvoiceItems({
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<>
|
||||
{/* Desktop Header Labels - Hidden on Mobile */}
|
||||
<div className="text-muted-foreground hidden items-center gap-3 px-4 pb-2 text-xs font-medium md:grid md:grid-cols-12">
|
||||
<div className="col-span-1"></div>
|
||||
<div className="col-span-2">Date</div>
|
||||
<div className="col-span-4">Description</div>
|
||||
<div className="col-span-1">Hours</div>
|
||||
<div className="col-span-2">Rate</div>
|
||||
<div className="col-span-1">Amount</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onItemChange={handleItemChange}
|
||||
onRemove={onRemoveItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onItemChange={handleItemChange}
|
||||
onRemove={onRemoveItem}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function InvoiceList() {
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
<span className="text-icon-green text-lg font-bold">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -136,11 +136,11 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900 dark:text-white">
|
||||
<FileText className="text-muted mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="text-accent mb-2 text-lg font-medium">
|
||||
Invoice not found
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted mb-4">
|
||||
The invoice you're looking for doesn't exist or has been
|
||||
deleted.
|
||||
</p>
|
||||
@@ -160,7 +160,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
{isOverdue && (
|
||||
<Card className="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<div className="text-error flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="font-medium">This invoice is overdue</span>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardContent>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-4">
|
||||
@@ -244,7 +244,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
</Card>
|
||||
|
||||
{/* Client Information */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<User className="h-5 w-5" />
|
||||
@@ -307,7 +307,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
@@ -367,7 +367,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Notes
|
||||
@@ -385,7 +385,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status Actions */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Status Actions
|
||||
@@ -437,7 +437,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Summary */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Summary
|
||||
@@ -476,7 +476,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-0 border-red-200 bg-white/80 shadow-xl backdrop-blur-sm dark:border-red-800 dark:bg-gray-800/80">
|
||||
<Card className="card-primary border-red-200 dark:border-red-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-700 dark:text-red-400">
|
||||
Danger Zone
|
||||
@@ -498,7 +498,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="border-0 bg-white/95 shadow-2xl backdrop-blur-sm dark:bg-gray-800/95">
|
||||
<DialogContent className="card-primary">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800 dark:text-white">
|
||||
Delete Invoice
|
||||
|
||||
@@ -94,7 +94,7 @@ export function StatsCard({
|
||||
|
||||
export function StatsCardSkeleton() {
|
||||
return (
|
||||
<Card className="border-0 shadow-md">
|
||||
<Card className="card-primary">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Badge, type badgeVariants } from "~/components/ui/badge";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type StatusType =
|
||||
| "draft"
|
||||
@@ -18,18 +18,15 @@ interface StatusBadgeProps
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const statusVariantMap: Record<
|
||||
StatusType,
|
||||
VariantProps<typeof badgeVariants>["variant"]
|
||||
> = {
|
||||
draft: "outline-draft",
|
||||
sent: "outline-sent",
|
||||
paid: "outline-paid",
|
||||
overdue: "outline-overdue",
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
info: "info",
|
||||
const statusClassMap: Record<StatusType, string> = {
|
||||
draft: "status-badge-draft",
|
||||
sent: "status-badge-sent",
|
||||
paid: "status-badge-paid",
|
||||
overdue: "status-badge-overdue",
|
||||
success: "badge-success",
|
||||
warning: "badge-warning",
|
||||
error: "badge-error",
|
||||
info: "badge-features",
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<StatusType, string> = {
|
||||
@@ -43,12 +40,17 @@ const statusLabelMap: Record<StatusType, string> = {
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, children, ...props }: StatusBadgeProps) {
|
||||
const variant = statusVariantMap[status];
|
||||
export function StatusBadge({
|
||||
status,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: StatusBadgeProps) {
|
||||
const statusClass = statusClassMap[status];
|
||||
const label = children || statusLabelMap[status];
|
||||
|
||||
return (
|
||||
<Badge variant={variant} {...props}>
|
||||
<Badge className={cn(statusClass, className)} {...props}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -255,11 +255,11 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
{/* Main Form Container - styled like data table */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||
<Building className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
||||
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<Building className="text-brand-light h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
@@ -376,12 +376,12 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<svg
|
||||
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
|
||||
className="text-brand-light h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -424,11 +424,11 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
<Card>
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||
<Star className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
||||
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<Star className="text-brand-light h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
@@ -439,7 +439,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border-border/40 flex items-center justify-between rounded-xl border bg-gradient-to-r from-emerald-600/5 to-teal-600/5 p-4">
|
||||
<div className="bg-brand-muted border-border/40 flex items-center justify-between rounded-xl border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="isDefault" className="text-base font-medium">
|
||||
Default Business
|
||||
@@ -485,7 +485,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isDirty}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
className="btn-brand-primary shadow-md"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
@@ -525,18 +525,22 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isDirty}
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||
className="btn-brand-primary shadow-md"
|
||||
size="sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
<span className="hidden sm:inline">{mode === "create" ? "Creating..." : "Saving..."}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{mode === "create" ? "Creating..." : "Saving..."}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{mode === "create" ? "Create Business" : "Save Changes"}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{mode === "create" ? "Create Business" : "Save Changes"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -221,7 +221,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
{/* Main Form Container - styled like data table */}
|
||||
<div className="space-y-4">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||
@@ -300,7 +300,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||
@@ -348,7 +348,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</Card>
|
||||
|
||||
{/* Billing Information */}
|
||||
<Card>
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||
|
||||
@@ -59,7 +59,7 @@ function InvoiceFormSkeleton() {
|
||||
</div>
|
||||
|
||||
{/* Invoice Details Card */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
|
||||
@@ -118,7 +118,7 @@ function InvoiceFormSkeleton() {
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items Card */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
|
||||
@@ -172,7 +172,7 @@ function InvoiceFormSkeleton() {
|
||||
|
||||
{/* Right Column - Summary */}
|
||||
<div className="space-y-6">
|
||||
<Card className="sticky top-6 shadow-sm">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-5 w-5 animate-pulse rounded"></div>
|
||||
@@ -493,7 +493,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</TabsList>
|
||||
<TabsContent value="invoice-details">
|
||||
{/* Invoice Details */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
@@ -554,6 +554,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
issueDate: date ?? new Date(),
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -566,6 +567,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
dueDate: date ?? new Date(),
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -681,7 +683,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="invoice-items">
|
||||
<Card className="shadow-sm">
|
||||
<Card className="card-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
@@ -705,7 +707,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
|
||||
{/* Right Column - Summary (Always Visible) */}
|
||||
<div className="space-y-6">
|
||||
<Card className="sticky top-6 shadow-sm">
|
||||
<Card className="card-primary sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
@@ -62,28 +63,27 @@ function LineItemRow({
|
||||
onUpdate,
|
||||
}: LineItemRowProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Layout - Table Row */}
|
||||
<tr className="group hover:bg-muted/20 hidden transition-colors lg:table-row">
|
||||
<div className="card-secondary hidden rounded-lg p-4 md:block">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Drag Handle */}
|
||||
<td className="w-6 p-2 text-center align-top">
|
||||
<GripVertical className="text-muted-foreground mt-1 h-4 w-4 cursor-grab" />
|
||||
</td>
|
||||
<div className="mt-1 flex items-center justify-center">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab" />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<td className="p-2" colSpan={5}>
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Description */}
|
||||
<div className="mb-3">
|
||||
<div>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||
placeholder="Describe the work performed..."
|
||||
className="w-full border-0 bg-transparent py-0 pr-0 pl-2 text-sm font-medium focus-visible:ring-0"
|
||||
className="w-full text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Date */}
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
@@ -91,7 +91,7 @@ function LineItemRow({
|
||||
onUpdate(index, "date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="h-9 w-28"
|
||||
className="h-9 w-36"
|
||||
/>
|
||||
|
||||
{/* Hours */}
|
||||
@@ -101,7 +101,7 @@ function LineItemRow({
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="auto"
|
||||
className="h-9 w-28"
|
||||
className="h-9 w-32"
|
||||
/>
|
||||
|
||||
{/* Rate */}
|
||||
@@ -112,7 +112,7 @@ function LineItemRow({
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="auto"
|
||||
className="h-9 w-28"
|
||||
className="h-9 w-32"
|
||||
/>
|
||||
|
||||
{/* Amount */}
|
||||
@@ -138,80 +138,9 @@ function LineItemRow({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Tablet Layout - Condensed Row */}
|
||||
<tr className="group hover:bg-muted/20 hidden transition-colors md:table-row lg:hidden">
|
||||
{/* Drag Handle */}
|
||||
<td className="w-6 p-2 text-center align-top">
|
||||
<GripVertical className="text-muted-foreground mt-1 h-4 w-4 cursor-grab" />
|
||||
</td>
|
||||
|
||||
{/* Main Content - Description on top, inputs below */}
|
||||
<td className="p-3" colSpan={6}>
|
||||
{/* Description */}
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||
placeholder="Describe the work performed..."
|
||||
className="w-full pl-3 text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls Row - Date/Hours/Rate break to separate rows on smaller screens */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) =>
|
||||
onUpdate(index, "date", date ?? new Date())
|
||||
}
|
||||
size="sm"
|
||||
className="h-9 w-full sm:w-28"
|
||||
/>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
className="h-9 w-1/2 sm:w-28"
|
||||
/>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
className="h-9 w-1/2 sm:w-28"
|
||||
/>
|
||||
|
||||
{/* Amount and Actions - inline with controls on larger screens */}
|
||||
<div className="mt-3 flex items-center justify-between sm:mt-0 sm:ml-auto sm:gap-3">
|
||||
<span className="text-primary font-semibold">
|
||||
${(item.hours * item.rate).toFixed(2)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className={cn(
|
||||
"text-muted-foreground h-8 w-8 p-0 transition-colors hover:text-red-500",
|
||||
!canRemove && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
disabled={!canRemove}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,55 +156,57 @@ function MobileLineItem({
|
||||
isLast,
|
||||
}: LineItemRowProps) {
|
||||
return (
|
||||
<div className="bg-card space-y-3 rounded-lg border p-4 md:hidden">
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate in a row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="card-secondary space-y-3 rounded-lg md:hidden">
|
||||
<div className="space-y-3 p-4">
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
<Label className="text-muted-foreground text-xs">Description</Label>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => onUpdate(index, "description", e.target.value)}
|
||||
placeholder="Describe the work performed..."
|
||||
className="pl-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
<Label className="text-muted-foreground text-xs">Date</Label>
|
||||
<DatePicker
|
||||
date={item.date}
|
||||
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours and Rate in a row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Hours</Label>
|
||||
<NumberInput
|
||||
value={item.hours}
|
||||
onChange={(value) => onUpdate(index, "hours", value)}
|
||||
min={0}
|
||||
step={0.25}
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-xs">Rate</Label>
|
||||
<NumberInput
|
||||
value={item.rate}
|
||||
onChange={(value) => onUpdate(index, "rate", value)}
|
||||
min={0}
|
||||
step={1}
|
||||
prefix="$"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section with controls, item name, and total */}
|
||||
<div className="flex items-center justify-between border-t pt-2">
|
||||
<div className="flex items-center justify-between rounded-b-lg border-t border-slate-400/60 bg-slate-200/30 px-4 py-2 dark:border-slate-500/60 dark:bg-slate-700/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -355,71 +286,36 @@ export function InvoiceLineItems({
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* Desktop and Tablet Table */}
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full">
|
||||
{/* Desktop Header */}
|
||||
<thead className="bg-muted/30 hidden lg:table-header-group">
|
||||
<tr>
|
||||
<th className="w-6 p-2"></th>
|
||||
<th
|
||||
className="text-muted-foreground p-2 text-left text-xs font-medium"
|
||||
colSpan={5}
|
||||
>
|
||||
Invoice Items
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Tablet Header */}
|
||||
<thead className="bg-muted/30 md:table-header-group lg:hidden">
|
||||
<tr>
|
||||
<th className="w-6 p-2"></th>
|
||||
<th
|
||||
className="text-muted-foreground p-2 text-left text-xs font-medium"
|
||||
colSpan={6}
|
||||
>
|
||||
Invoice Items
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y">
|
||||
{items.map((item, index) => (
|
||||
<LineItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{/* Desktop and Mobile Cards */}
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<MobileLineItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
/>
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Desktop/Tablet Card */}
|
||||
<LineItemRow
|
||||
item={item}
|
||||
index={index}
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
/>
|
||||
|
||||
{/* Mobile Card */}
|
||||
<MobileLineItem
|
||||
item={item}
|
||||
index={index}
|
||||
canRemove={canRemoveItems}
|
||||
onRemove={onRemoveItem}
|
||||
onUpdate={onUpdateItem}
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -97,19 +97,11 @@ export function FloatingActionBar({
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={floatingRef}
|
||||
className={cn(
|
||||
"border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 sticky bottom-4 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{leftContent || (
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
)}
|
||||
<div ref={floatingRef} className={cn("floating-action-bar", className)}>
|
||||
<div className="floating-action-bar-content">
|
||||
{leftContent || <p className="floating-action-bar-title">{title}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3">{children}</div>
|
||||
<div className="floating-action-bar-actions">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function PageHeader({
|
||||
|
||||
switch (variant) {
|
||||
case "gradient":
|
||||
return `${baseClasses} text-3xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
|
||||
return `${baseClasses} text-3xl text-brand-gradient`;
|
||||
case "large":
|
||||
return `${baseClasses} text-4xl text-foreground`;
|
||||
case "large-gradient":
|
||||
return `${baseClasses} text-4xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
|
||||
return `${baseClasses} text-4xl text-brand-gradient`;
|
||||
default:
|
||||
return `${baseClasses} text-3xl text-foreground`;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function QuickActionCard({
|
||||
|
||||
export function QuickActionCardSkeleton() {
|
||||
return (
|
||||
<Card className="border-0 shadow-md">
|
||||
<Card className="card-primary">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mx-auto mb-3 h-12 w-12 rounded-full"></div>
|
||||
|
||||
@@ -10,22 +10,26 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
"border-slate-300 bg-slate-200 text-slate-800 shadow-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
"border-slate-300 bg-slate-200/80 text-slate-700 shadow-sm dark:border-slate-600 dark:bg-slate-700/80 dark:text-slate-300",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
"border-2 border-slate-300 bg-transparent text-slate-700 dark:border-slate-600 dark:text-slate-300",
|
||||
success: "border-transparent bg-status-success [a&]:hover:opacity-90",
|
||||
warning: "border-transparent bg-status-warning [a&]:hover:opacity-90",
|
||||
error: "border-transparent bg-status-error [a&]:hover:opacity-90",
|
||||
info: "border-transparent bg-status-info [a&]:hover:opacity-90",
|
||||
// Outlined variants for status badges
|
||||
"outline-draft": "border-gray-400 text-gray-600 dark:border-gray-500 dark:text-gray-300 bg-transparent",
|
||||
"outline-sent": "border-blue-400 text-blue-600 dark:border-blue-500 dark:text-blue-300 bg-transparent",
|
||||
"outline-paid": "border-green-400 text-green-600 dark:border-green-500 dark:text-green-300 bg-transparent",
|
||||
"outline-overdue": "border-red-400 text-red-600 dark:border-red-500 dark:text-red-300 bg-transparent",
|
||||
"outline-draft":
|
||||
"border-gray-400 text-gray-600 dark:border-gray-500 dark:text-gray-300 bg-transparent",
|
||||
"outline-sent":
|
||||
"border-blue-400 text-blue-600 dark:border-blue-500 dark:text-blue-300 bg-transparent",
|
||||
"outline-paid":
|
||||
"border-green-400 text-green-600 dark:border-green-500 dark:text-green-300 bg-transparent",
|
||||
"outline-overdue":
|
||||
"border-red-400 text-red-600 dark:border-red-500 dark:text-red-300 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
} from "lucide-react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Button, buttonVariants } from "~/components/ui/button"
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
@@ -19,11 +26,33 @@ function Calendar({
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
month,
|
||||
onMonthChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
const currentYear = month?.getFullYear() || new Date().getFullYear();
|
||||
const currentMonth = month?.getMonth() || new Date().getMonth();
|
||||
|
||||
const years = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i);
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
@@ -32,94 +61,82 @@ function Calendar({
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
month={month}
|
||||
onMonthChange={onMonthChange}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
"relative has-focus:border-ring border border-input shadow-sm has-focus:ring-ring/50 has-focus:ring-2 rounded-md h-8",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
"absolute bg-transparent inset-0 w-full h-full opacity-0 cursor-pointer",
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
"select-none font-medium text-sm hidden",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
"text-muted-foreground flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
"relative w-full h-full p-0 text-center group/day aspect-square select-none",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn("", defaultClassNames.range_start),
|
||||
range_middle: cn("", defaultClassNames.range_middle),
|
||||
range_end: cn("", defaultClassNames.range_end),
|
||||
today: cn("font-semibold", defaultClassNames.today),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
@@ -133,13 +150,13 @@ function Calendar({
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
@@ -148,14 +165,67 @@ function Calendar({
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
MonthCaption: ({ calendarMonth }) => {
|
||||
if (captionLayout !== "dropdown") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-custom-header flex items-center justify-center gap-2 py-2">
|
||||
<Select
|
||||
value={currentMonth.toString()}
|
||||
onValueChange={(value) => {
|
||||
const newDate = new Date(currentYear, parseInt(value), 1);
|
||||
onMonthChange?.(newDate);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-auto px-2 text-sm font-semibold"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((monthName, index) => (
|
||||
<SelectItem key={index} value={index.toString()}>
|
||||
{monthName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={currentYear.toString()}
|
||||
onValueChange={(value) => {
|
||||
const newDate = new Date(parseInt(value), currentMonth, 1);
|
||||
onMonthChange?.(newDate);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-auto px-2 text-sm font-semibold"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
@@ -163,13 +233,13 @@ function Calendar({
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
@@ -178,12 +248,12 @@ function CalendarDayButton({
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -201,13 +271,14 @@ function CalendarDayButton({
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
"hover:bg-accent hover:text-accent-foreground flex aspect-square size-auto h-8 w-full min-w-8 items-center justify-center rounded-md border-0 text-sm leading-none font-normal shadow-none",
|
||||
modifiers.selected && "bg-primary text-primary-foreground",
|
||||
modifiers.today && !modifiers.selected && "bg-accent font-semibold",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
export { Calendar, CalendarDayButton };
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-background/60 text-card-foreground border-border/40 flex flex-col gap-6 rounded-2xl border py-6 shadow-lg backdrop-blur-xl backdrop-saturate-150",
|
||||
"bg-background/60 text-card-foreground border-border/40 flex flex-col gap-2 rounded-2xl border py-2 shadow-lg backdrop-blur-xl backdrop-saturate-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-3 px-5 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
className={cn("px-5 pb-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -75,7 +75,7 @@ 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)}
|
||||
className={cn("flex items-center px-6 py-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { parseDate } from "chrono-node";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -13,6 +14,18 @@ import {
|
||||
} from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function formatDate(date: Date | undefined) {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface DatePickerProps {
|
||||
date?: Date;
|
||||
onDateChange: (date: Date | undefined) => void;
|
||||
@@ -26,13 +39,15 @@ interface DatePickerProps {
|
||||
export function DatePicker({
|
||||
date,
|
||||
onDateChange,
|
||||
placeholder = "Select date",
|
||||
placeholder = "Tomorrow or next week",
|
||||
className,
|
||||
disabled = false,
|
||||
id,
|
||||
size = "md",
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState(formatDate(date));
|
||||
const [month, setMonth] = React.useState<Date | undefined>(date);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-9 text-xs",
|
||||
@@ -40,42 +55,68 @@ export function DatePicker({
|
||||
lg: "h-10 text-sm",
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
if (size === "sm") {
|
||||
return format(date, "MMM dd");
|
||||
}
|
||||
return format(date, "PPP");
|
||||
};
|
||||
const inputWidthClass = className?.includes("w-full")
|
||||
? "w-full"
|
||||
: className?.includes("w-32") ||
|
||||
className?.includes("w-28") ||
|
||||
className?.includes("w-36")
|
||||
? className
|
||||
: "w-full md:w-32 md:min-w-32";
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(formatDate(date));
|
||||
setMonth(date);
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
sizeClasses[size],
|
||||
!date && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{date ? formatDate(date) : placeholder}
|
||||
<CalendarIcon className="text-muted-foreground h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(selectedDate: Date | undefined) => {
|
||||
onDateChange(selectedDate);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn("bg-background pr-10", sizeClasses[size], "w-full")}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
const parsedDate = parseDate(e.target.value);
|
||||
if (parsedDate) {
|
||||
onDateChange(parsedDate);
|
||||
setMonth(parsedDate);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
className="absolute top-1/2 right-2 size-6 -translate-y-1/2"
|
||||
>
|
||||
<CalendarIcon className="size-3.5" />
|
||||
<span className="sr-only">Select date</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={(selectedDate) => {
|
||||
onDateChange(selectedDate);
|
||||
setValue(formatDate(selectedDate));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,12 +71,12 @@ export function NumberInput({
|
||||
onChange(Math.max(min, (value || 0) - step));
|
||||
};
|
||||
|
||||
const widthClass = width === "full" ? "w-full" : "w-24";
|
||||
const widthClass = width === "full" ? "w-full" : "w-24 min-w-24";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background flex h-9 items-center justify-center rounded-md border px-2 text-sm",
|
||||
"bg-background flex h-9 items-center justify-center rounded-md text-sm shadow-none",
|
||||
widthClass,
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
@@ -103,7 +103,7 @@ export function NumberInput({
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-16 border-0 bg-transparent text-center outline-none focus-visible:ring-0"
|
||||
className="number-input-field w-full border-0 bg-transparent text-center ring-0 outline-none focus:border-transparent focus:ring-0 focus:outline-none focus-visible:ring-0"
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="text-muted-foreground text-xs">{suffix}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user