Datepicker redesign + DB connection fixes for Prod
This commit is contained in:
@@ -142,3 +142,34 @@ Point your domain (e.g., `gym.yourdomain.com`) to the NAS IP and the mapped port
|
|||||||
- Forward Hostname / IP: `[NAS_IP]`
|
- Forward Hostname / IP: `[NAS_IP]`
|
||||||
- Forward Port: `3033`
|
- Forward Port: `3033`
|
||||||
- Websockets Support: Enable (if needed for future features).
|
- Websockets Support: Enable (if needed for future features).
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
### "Readonly Database" Error
|
||||||
|
If you see an error like `Invalid prisma.userProfile.upsert() invocation: attempt to write a readonly database`:
|
||||||
|
|
||||||
|
1. **Verify Permissions:** Run the diagnostic script inside your container:
|
||||||
|
```bash
|
||||||
|
docker exec -it node-apps node /usr/src/app/gymflow/server/check_db_perms.js
|
||||||
|
```
|
||||||
|
2. **Fix Permissions:** If the checks fail, run these commands on your NAS inside the `gymflow/server` directory:
|
||||||
|
```bash
|
||||||
|
sudo chmod 777 .
|
||||||
|
sudo chmod 666 prod.db
|
||||||
|
```
|
||||||
|
*Note: SQLite needs write access to the directory itself to create temporary journaling files (`-wal`, `-shm`).*
|
||||||
|
|
||||||
|
3. **Check Docker User:** Alternatively, ensure your Docker container is running as a user who owns these files (e.g., set `user: "1000:1000"` in `docker-compose.yml` if your NAS user has that ID).
|
||||||
|
|
||||||
|
### "Invalid ELF Header" Error
|
||||||
|
If you see an error like `invalid ELF header` for `better-sqlite3.node`:
|
||||||
|
This happens because the `node_modules` contains Windows binaries (from your local machine) instead of Linux binaries.
|
||||||
|
|
||||||
|
1. **Fix Inside Container:** Run the following command to force a rebuild of native modules for Linux:
|
||||||
|
```bash
|
||||||
|
docker exec -it node-apps /bin/sh -c "cd /usr/src/app/gymflow/server && npm rebuild better-sqlite3"
|
||||||
|
```
|
||||||
|
2. **Restart Container:** After rebuilding, restart the container:
|
||||||
|
```bash
|
||||||
|
docker-compose restart nodejs-apps
|
||||||
|
```
|
||||||
|
|||||||
29
server/check_adapter_props.js
Normal file
29
server/check_adapter_props.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
console.log('--- Prisma Adapter Diagnostic ---');
|
||||||
|
const factory = new PrismaBetterSqlite3({ url: 'file:./dev.db' });
|
||||||
|
|
||||||
|
console.log('Factory Properties:');
|
||||||
|
console.log(Object.keys(factory));
|
||||||
|
console.log('Factory.adapterName:', factory.adapterName);
|
||||||
|
console.log('Factory.provider:', factory.provider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = await factory.connect();
|
||||||
|
console.log('\nAdapter Properties:');
|
||||||
|
console.log(Object.keys(adapter));
|
||||||
|
console.log('Adapter name:', adapter.adapterName);
|
||||||
|
console.log('Adapter provider:', adapter.provider);
|
||||||
|
|
||||||
|
// Also check if there are hidden/prototype properties
|
||||||
|
let proto = Object.getPrototypeOf(adapter);
|
||||||
|
console.log('Adapter Prototype Properties:', Object.getOwnPropertyNames(proto));
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to connect:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
45
server/check_db_perms.js
Normal file
45
server/check_db_perms.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.resolve(__dirname, 'prod.db');
|
||||||
|
const dirPath = __dirname;
|
||||||
|
|
||||||
|
console.log('--- GymFlow Database Permission Check ---');
|
||||||
|
console.log(`Checking directory: ${dirPath}`);
|
||||||
|
console.log(`Checking file: ${dbPath}`);
|
||||||
|
|
||||||
|
// 1. Check Directory
|
||||||
|
try {
|
||||||
|
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
console.log('✅ Directory is readable and writable.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Directory is NOT writable! SQLite needs directory write access to create temporary files.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check File
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(dbPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
console.log('✅ Database file is readable and writable.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Database file is NOT writable!');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Database file does not exist yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try to write a test file in the directory
|
||||||
|
const testFile = path.join(dirPath, '.write_test');
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(testFile, 'test');
|
||||||
|
fs.unlinkSync(testFile);
|
||||||
|
console.log('✅ Successfully performed a test write in the directory.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed test write in directory: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- Recommendation ---');
|
||||||
|
console.log('If any checks failed, run these commands on your NAS (in the gymflow/server folder):');
|
||||||
|
console.log('1. sudo chmod 777 .');
|
||||||
|
console.log('2. sudo chmod 666 prod.db');
|
||||||
|
console.log('\nAlternatively, ensure your Docker container is running with a user that owns these files.');
|
||||||
Binary file not shown.
BIN
server/prod.db
BIN
server/prod.db
Binary file not shown.
@@ -54,10 +54,33 @@ async function resetDb() {
|
|||||||
// 4. Create the Admin user
|
// 4. Create the Admin user
|
||||||
console.log(`Creating fresh admin user...`);
|
console.log(`Creating fresh admin user...`);
|
||||||
|
|
||||||
// In Prisma 7, we must use the adapter for better-sqlite3
|
// In Prisma 7, PrismaBetterSqlite3 is a factory.
|
||||||
|
// We use the factory to create the adapter, then we access the internal client
|
||||||
|
// to disable WAL mode for NAS/Network share compatibility (journal_mode = DELETE).
|
||||||
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3');
|
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3');
|
||||||
const adapter = new PrismaBetterSqlite3({ url: dbPath });
|
const factory = new PrismaBetterSqlite3({ url: dbPath });
|
||||||
const prisma = new PrismaClient({ adapter });
|
|
||||||
|
const adapterWrapper = {
|
||||||
|
provider: 'sqlite',
|
||||||
|
adapterName: '@prisma/adapter-better-sqlite3',
|
||||||
|
async connect() {
|
||||||
|
const adapter = await factory.connect();
|
||||||
|
if (adapter.client) {
|
||||||
|
console.log(`Setting journal_mode = DELETE for NAS compatibility`);
|
||||||
|
adapter.client.pragma('journal_mode = DELETE');
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
},
|
||||||
|
async connectToShadowDb() {
|
||||||
|
const adapter = await factory.connectToShadowDb();
|
||||||
|
if (adapter.client) {
|
||||||
|
adapter.client.pragma('journal_mode = DELETE');
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter: adapterWrapper });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hashedPassword = await bcrypt.hash(adminPassword, 10);
|
const hashedPassword = await bcrypt.hash(adminPassword, 10);
|
||||||
|
|||||||
@@ -35,13 +35,36 @@ console.log('Initializing Prisma Client with database:', dbPath);
|
|||||||
|
|
||||||
let prisma: PrismaClient;
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
// In Prisma 7, PrismaBetterSqlite3 is a factory.
|
||||||
|
// We use a wrapper to intercept the connection and disable WAL mode
|
||||||
|
// for NAS/Network share compatibility (journal_mode = DELETE).
|
||||||
try {
|
try {
|
||||||
const adapter = new PrismaBetterSqlite3({ url: dbPath });
|
const factory = new PrismaBetterSqlite3({ url: dbPath });
|
||||||
|
|
||||||
|
const adapterWrapper = {
|
||||||
|
provider: 'sqlite',
|
||||||
|
adapterName: '@prisma/adapter-better-sqlite3',
|
||||||
|
async connect() {
|
||||||
|
const adapter = (await factory.connect()) as any;
|
||||||
|
if (adapter.client) {
|
||||||
|
console.log('[Prisma] Setting journal_mode = DELETE for NAS compatibility');
|
||||||
|
adapter.client.pragma('journal_mode = DELETE');
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
},
|
||||||
|
async connectToShadowDb() {
|
||||||
|
const adapter = (await factory.connectToShadowDb()) as any;
|
||||||
|
if (adapter.client) {
|
||||||
|
adapter.client.pragma('journal_mode = DELETE');
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
prisma =
|
prisma =
|
||||||
global.prisma ||
|
global.prisma ||
|
||||||
new PrismaClient({
|
new PrismaClient({
|
||||||
adapter: adapter as any,
|
adapter: adapterWrapper as any,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Failed to initialize Prisma Client:', e.message);
|
console.error('Failed to initialize Prisma Client:', e.message);
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
|||||||
value={birthDate}
|
value={birthDate}
|
||||||
onChange={(val) => setBirthDate(val)}
|
onChange={(val) => setBirthDate(val)}
|
||||||
testId="profile-birth-date"
|
testId="profile-birth-date"
|
||||||
|
maxDate={new Date()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useId } from 'react';
|
import React, { useState, useRef, useEffect, useId } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { Ripple } from './Ripple';
|
import { Ripple } from './Ripple';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ interface DatePickerProps {
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
maxDate?: Date; // Optional maximum date constraint
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DatePicker: React.FC<DatePickerProps> = ({
|
export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
@@ -21,7 +22,8 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
placeholder = 'Select date',
|
placeholder = 'Select date',
|
||||||
icon = <CalendarIcon size={16} />,
|
icon = <CalendarIcon size={16} />,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
testId
|
testId,
|
||||||
|
maxDate
|
||||||
}) => {
|
}) => {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -30,6 +32,11 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// MD3 Enhancement: Calendar view and text input states
|
||||||
|
const [calendarView, setCalendarView] = useState<'days' | 'months' | 'years'>('days');
|
||||||
|
const [textInputValue, setTextInputValue] = useState('');
|
||||||
|
const [textInputError, setTextInputError] = useState('');
|
||||||
|
|
||||||
// Update popover position when opening or when window resizes
|
// Update popover position when opening or when window resizes
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
@@ -100,6 +107,10 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
const nextOpen = !isOpen;
|
const nextOpen = !isOpen;
|
||||||
if (nextOpen) {
|
if (nextOpen) {
|
||||||
updatePosition();
|
updatePosition();
|
||||||
|
// MD3 Enhancement: Reset to days view when opening
|
||||||
|
setCalendarView('days');
|
||||||
|
setTextInputValue('');
|
||||||
|
setTextInputError('');
|
||||||
}
|
}
|
||||||
setIsOpen(nextOpen);
|
setIsOpen(nextOpen);
|
||||||
}
|
}
|
||||||
@@ -122,22 +133,156 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MD3 Enhancement: Year selection handler
|
||||||
|
const handleYearSelect = (year: number) => {
|
||||||
|
setViewDate(new Date(year, viewDate.getMonth(), 1));
|
||||||
|
setCalendarView('days');
|
||||||
|
};
|
||||||
|
|
||||||
|
// MD3 Enhancement: Text input validation and handling
|
||||||
|
const validateDateInput = (input: string): boolean => {
|
||||||
|
// Check format YYYY-MM-DD
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!dateRegex.test(input)) {
|
||||||
|
setTextInputError('Format: YYYY-MM-DD');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
const date = new Date(input);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
setTextInputError('Invalid date');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the input matches the parsed date (catches invalid dates like 2023-02-30)
|
||||||
|
const [year, month, day] = input.split('-').map(Number);
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() + 1 !== month || date.getDate() !== day) {
|
||||||
|
setTextInputError('Invalid date');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against maxDate constraint
|
||||||
|
if (maxDate && date > maxDate) {
|
||||||
|
setTextInputError('Date cannot be in the future');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextInputError('');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextInputSubmit = () => {
|
||||||
|
if (validateDateInput(textInputValue)) {
|
||||||
|
onChange(textInputValue);
|
||||||
|
setIsOpen(false);
|
||||||
|
setTextInputValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||||
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||||
|
|
||||||
|
|
||||||
const renderCalendar = () => {
|
const renderCalendar = () => {
|
||||||
const year = viewDate.getFullYear();
|
const year = viewDate.getFullYear();
|
||||||
const month = viewDate.getMonth();
|
const month = viewDate.getMonth();
|
||||||
const daysCount = daysInMonth(year, month);
|
const daysCount = daysInMonth(year, month);
|
||||||
const startingDay = firstDayOfMonth(year, month);
|
const startingDay = firstDayOfMonth(year, month);
|
||||||
const monthName = viewDate.toLocaleString('default', { month: 'long' });
|
const monthName = viewDate.toLocaleString('en-US', { month: 'long' });
|
||||||
|
const shortMonthName = viewDate.toLocaleString('en-US', { month: 'short' });
|
||||||
|
|
||||||
|
// Format selected date for header display
|
||||||
|
const selectedDate = value ? new Date(value) : null;
|
||||||
|
const headerDateText = selectedDate
|
||||||
|
? selectedDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
: 'Select date';
|
||||||
|
|
||||||
|
// Render year selection view
|
||||||
|
const renderYearsView = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const maxYear = maxDate ? maxDate.getFullYear() : currentYear + 100;
|
||||||
|
const years = [];
|
||||||
|
// Generate years from current year going backwards (recent first)
|
||||||
|
for (let y = maxYear; y >= currentYear - 100; y--) {
|
||||||
|
years.push(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedYear = selectedDate?.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64 overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-3 gap-2 p-2">
|
||||||
|
{years.map(y => (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleYearSelect(y)}
|
||||||
|
className={`
|
||||||
|
relative py-3 rounded-lg text-sm transition-colors
|
||||||
|
${y === year
|
||||||
|
? 'bg-primary text-on-primary font-bold'
|
||||||
|
: y === currentYear
|
||||||
|
? 'text-primary border border-primary font-medium'
|
||||||
|
: 'text-on-surface hover:bg-surface-container-high'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Ripple color={y === year ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render month selection view
|
||||||
|
const renderMonthsView = () => {
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64 overflow-y-auto">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{months.map((monthLabel, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setViewDate(new Date(year, i, 1));
|
||||||
|
setCalendarView('days');
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
relative w-full text-left px-4 py-3 rounded-lg text-sm transition-colors flex items-center gap-2
|
||||||
|
${i === month
|
||||||
|
? 'bg-secondary-container text-on-secondary-container font-medium'
|
||||||
|
: 'text-on-surface hover:bg-surface-container-high'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{i === month && (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className={i === month ? '' : 'ml-7'}>{monthLabel}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render days view
|
||||||
|
const renderDaysView = () => {
|
||||||
const days = [];
|
const days = [];
|
||||||
for (let i = 0; i < startingDay; i++) {
|
for (let i = 0; i < startingDay; i++) {
|
||||||
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
|
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDate = value ? new Date(value) : null;
|
|
||||||
const isSelected = (d: number) => {
|
const isSelected = (d: number) => {
|
||||||
return selectedDate &&
|
return selectedDate &&
|
||||||
selectedDate.getFullYear() === year &&
|
selectedDate.getFullYear() === year &&
|
||||||
@@ -152,15 +297,26 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
today.getDate() === d;
|
today.getDate() === d;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if a date is disabled (after maxDate)
|
||||||
|
const isDisabled = (d: number) => {
|
||||||
|
if (!maxDate) return false;
|
||||||
|
const checkDate = new Date(year, month, d);
|
||||||
|
return checkDate > maxDate;
|
||||||
|
};
|
||||||
|
|
||||||
for (let d = 1; d <= daysCount; d++) {
|
for (let d = 1; d <= daysCount; d++) {
|
||||||
|
const disabled = isDisabled(d);
|
||||||
days.push(
|
days.push(
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDateSelect(d)}
|
onClick={() => !disabled && handleDateSelect(d)}
|
||||||
|
disabled={disabled}
|
||||||
className={`
|
className={`
|
||||||
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
|
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
|
||||||
${isSelected(d)
|
${disabled
|
||||||
|
? 'text-on-surface/30 cursor-not-allowed'
|
||||||
|
: isSelected(d)
|
||||||
? 'bg-primary text-on-primary font-bold'
|
? 'bg-primary text-on-primary font-bold'
|
||||||
: isToday(d)
|
: isToday(d)
|
||||||
? 'text-primary border border-primary font-medium'
|
? 'text-primary border border-primary font-medium'
|
||||||
@@ -168,35 +324,50 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
|
{!disabled && <Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />}
|
||||||
{d}
|
{d}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const calendarContent = (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={popoverRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: popoverPos.top,
|
|
||||||
left: popoverPos.left,
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
className="p-4 w-[320px] bg-surface-container-high rounded-2xl shadow-xl border border-outline-variant animate-in fade-in zoom-in duration-200 origin-top"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex flex-col">
|
{/* Month navigation */}
|
||||||
<span className="text-sm font-medium text-on-surface">{monthName} {year}</span>
|
<div className="flex items-center gap-1">
|
||||||
</div>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalendarView('months')}
|
||||||
|
className="flex items-center gap-1 hover:bg-surface-container rounded px-2 py-1 transition-colors text-sm font-medium text-on-surface"
|
||||||
|
>
|
||||||
|
{shortMonthName}
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Year navigation */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setViewDate(new Date(viewDate.getFullYear() - 1, viewDate.getMonth(), 1))} className="h-8 w-8">
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalendarView('years')}
|
||||||
|
className="flex items-center gap-1 hover:bg-surface-container rounded px-2 py-1 transition-colors text-sm font-medium text-on-surface"
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setViewDate(new Date(viewDate.getFullYear() + 1, viewDate.getMonth(), 1))} className="h-8 w-8">
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
@@ -210,7 +381,31 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{days}
|
{days}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarContent = (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: popoverPos.top,
|
||||||
|
left: popoverPos.left,
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
className="p-4 w-[320px] bg-surface-container-high rounded-2xl shadow-xl border border-outline-variant animate-in fade-in zoom-in duration-200 origin-top"
|
||||||
|
>
|
||||||
|
{/* Calendar content based on view */}
|
||||||
|
{calendarView === 'years' ? (
|
||||||
|
renderYearsView()
|
||||||
|
) : calendarView === 'months' ? (
|
||||||
|
renderMonthsView()
|
||||||
|
) : (
|
||||||
|
renderDaysView()
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer with Cancel button */}
|
||||||
<div className="mt-4 pt-2 border-t border-outline-variant flex justify-end gap-2">
|
<div className="mt-4 pt-2 border-t border-outline-variant flex justify-end gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
|
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -222,17 +417,17 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
return createPortal(calendarContent, document.body);
|
return createPortal(calendarContent, document.body);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formattedValue = value ? new Date(value).toLocaleDateString() : '';
|
// Display value in same format as editing (YYYY-MM-DD)
|
||||||
|
const formattedValue = value || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full" ref={containerRef}>
|
<div className="relative w-full" ref={containerRef}>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px] cursor-pointer
|
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px]
|
||||||
${isOpen ? 'border-primary border-b-2' : 'border-on-surface-variant hover:bg-on-surface/10'}
|
${isOpen ? 'border-primary border-b-2' : 'border-on-surface-variant hover:bg-on-surface/10'}
|
||||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
`}
|
`}
|
||||||
onClick={handleInputClick}
|
|
||||||
>
|
>
|
||||||
<label className={`
|
<label className={`
|
||||||
absolute top-2 left-4 text-label-sm font-medium transition-colors flex items-center gap-1
|
absolute top-2 left-4 text-label-sm font-medium transition-colors flex items-center gap-1
|
||||||
@@ -241,13 +436,60 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||||||
{icon} {label}
|
{icon} {label}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface flex items-center">
|
<input
|
||||||
{formattedValue || <span className="text-on-surface-variant/50">{placeholder}</span>}
|
type="text"
|
||||||
</div>
|
value={textInputValue || formattedValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTextInputValue(e.target.value);
|
||||||
|
setTextInputError('');
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
// Initialize text input with current value and open calendar
|
||||||
|
setTextInputValue(value || '');
|
||||||
|
if (!disabled && !isOpen) {
|
||||||
|
updatePosition();
|
||||||
|
setCalendarView('days');
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Validate and apply on blur
|
||||||
|
if (textInputValue && textInputValue !== value) {
|
||||||
|
if (validateDateInput(textInputValue)) {
|
||||||
|
onChange(textInputValue);
|
||||||
|
setTextInputValue('');
|
||||||
|
}
|
||||||
|
} else if (!textInputValue) {
|
||||||
|
setTextInputValue('');
|
||||||
|
setTextInputError('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (textInputValue && validateDateInput(textInputValue)) {
|
||||||
|
onChange(textInputValue);
|
||||||
|
setTextInputValue('');
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface bg-transparent focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant">
|
{textInputError && (
|
||||||
|
<span className="absolute bottom-[-18px] left-4 text-xs text-error">{textInputError}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInputClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant hover:text-primary p-1 rounded-full transition-colors"
|
||||||
|
>
|
||||||
<CalendarIcon size={20} />
|
<CalendarIcon size={20} />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user