mirror of
https://github.com/duongcamcute/tech-gadget-manager.git
synced 2026-03-03 02:27:01 +00:00
feat: Cải thiện UI Dark Mode, Mobile và sửa lỗi mất dữ liệu Docker
## Thay đổi chính: ### UI/UX - Sửa lỗi Dark Mode trong LocationManager và SmartAddForm - Tối ưu Header, InventoryManager và UserMenu cho Mobile - Cải thiện độ tương phản và layout responsive ### Sửa lỗi ESLint - Sửa lỗi truy cập biến trước khai báo trong settings/page.tsx - Thay thế any type bằng kiểu cụ thể - Xóa import không sử dụng ### Docker & Database - Bổ sung .dockerignore loại trừ file SQLite - Cập nhật db.ts bắt buộc DATABASE_URL trong production - Thêm document hướng dẫn triển khai Docker ### Tài liệu - Thêm docs/DOCKER_DEPLOYMENT.md
This commit is contained in:
@@ -7,4 +7,7 @@ npm-debug.log
|
||||
.git
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.DS_Store
|
||||
|
||||
186
docs/DOCKER_DEPLOYMENT.md
Normal file
186
docs/DOCKER_DEPLOYMENT.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Hướng dẫn Triển khai Docker - Tech Gadget Manager
|
||||
|
||||
Tài liệu này giúp bạn triển khai ứng dụng bằng Docker một cách an toàn, tránh các lỗi phổ biến về dữ liệu.
|
||||
|
||||
---
|
||||
|
||||
## Yêu cầu
|
||||
|
||||
- Docker và Docker Compose đã được cài đặt
|
||||
- Khoảng 500MB dung lượng đĩa
|
||||
|
||||
---
|
||||
|
||||
## Triển khai Nhanh
|
||||
|
||||
### 1. Tải docker-compose.yml
|
||||
|
||||
```bash
|
||||
mkdir tech-gadget-manager && cd tech-gadget-manager
|
||||
curl -O https://raw.githubusercontent.com/duongcamcute/tech-gadget-manager/main/docker-compose.yml
|
||||
```
|
||||
|
||||
### 2. Tạo thư mục dữ liệu
|
||||
|
||||
```bash
|
||||
mkdir -p db uploads
|
||||
```
|
||||
|
||||
### 3. Khởi chạy
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 4. Truy cập
|
||||
|
||||
Mở trình duyệt: `http://localhost:3000`
|
||||
|
||||
**Tài khoản mặc định**: `admin` / `admin`
|
||||
|
||||
---
|
||||
|
||||
## Cấu hình Quan trọng
|
||||
|
||||
### ⚠️ DATABASE_URL (BẮT BUỘC)
|
||||
|
||||
`DATABASE_URL` là biến môi trường **BẮT BUỘC** cho Docker. Nếu thiếu, ứng dụng sẽ báo lỗi.
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/db/prod.db # ✅ BẮT BUỘC
|
||||
```
|
||||
|
||||
### Volumes (Lưu trữ dữ liệu)
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./db:/app/db # Database SQLite
|
||||
- ./uploads:/app/public/uploads # Ảnh upload
|
||||
```
|
||||
|
||||
> **Lưu ý**: Thư mục `./db` trên máy host sẽ chứa database. KHÔNG XÓA thư mục này.
|
||||
|
||||
---
|
||||
|
||||
## Cập nhật Phiên bản Mới
|
||||
|
||||
### Bước 1: Backup dữ liệu (QUAN TRỌNG!)
|
||||
|
||||
```bash
|
||||
cp -r ./db ./db_backup_$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Bước 2: Kéo image mới
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
### Bước 3: Khởi động lại
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Dữ liệu sẽ được giữ nguyên** vì được lưu trong thư mục `./db` trên máy host.
|
||||
|
||||
---
|
||||
|
||||
## Khắc phục Sự cố
|
||||
|
||||
### Lỗi: "DATABASE_URL is required in production"
|
||||
|
||||
**Nguyên nhân**: Thiếu biến `DATABASE_URL` trong docker-compose.yml
|
||||
|
||||
**Giải pháp**: Thêm dòng sau vào phần `environment`:
|
||||
```yaml
|
||||
- DATABASE_URL=file:/app/db/prod.db
|
||||
```
|
||||
|
||||
### Lỗi: Dữ liệu bị mất sau khi update
|
||||
|
||||
**Nguyên nhân có thể**:
|
||||
1. Chưa map volume `./db:/app/db`
|
||||
2. Xóa thư mục `./db` trước khi update
|
||||
|
||||
**Cách phòng tránh**:
|
||||
- Luôn backup trước khi update
|
||||
- KHÔNG xóa thư mục `./db`
|
||||
|
||||
### Lỗi: Permission denied trên database
|
||||
|
||||
```bash
|
||||
sudo chown -R 1001:1001 ./db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cấu hình Nâng cao
|
||||
|
||||
### Thay đổi JWT Secret (Khuyến nghị)
|
||||
|
||||
```yaml
|
||||
- JWT_SECRET=thay_bang_chuoi_ngau_nhien_dai_32_ky_tu
|
||||
```
|
||||
|
||||
### Bật HTTPS (Reverse Proxy)
|
||||
|
||||
Nếu dùng với Nginx/Traefik có SSL:
|
||||
```yaml
|
||||
- DISABLE_SECURE_COOKIES=false
|
||||
```
|
||||
|
||||
### Demo Mode
|
||||
|
||||
```yaml
|
||||
- NEXT_PUBLIC_DEMO_MODE=true # Chặn thao tác ghi dữ liệu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup & Restore
|
||||
|
||||
### Backup thủ công
|
||||
|
||||
```bash
|
||||
# Backup database
|
||||
cp ./db/prod.db ./backup_$(date +%Y%m%d).db
|
||||
|
||||
# Hoặc dùng tính năng Export trong app (Settings > Hệ thống > Xuất dữ liệu)
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# Dừng container
|
||||
docker compose down
|
||||
|
||||
# Thay thế database
|
||||
cp ./backup_20260125.db ./db/prod.db
|
||||
|
||||
# Khởi động lại
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thông tin Kỹ thuật
|
||||
|
||||
| Thành phần | Chi tiết |
|
||||
|------------|----------|
|
||||
| Base Image | `node:20-alpine` |
|
||||
| Database | SQLite (file-based) |
|
||||
| Port | 3000 |
|
||||
| User trong container | `nextjs` (UID 1001) |
|
||||
|
||||
---
|
||||
|
||||
## Hỗ trợ
|
||||
|
||||
- GitHub Issues: [Link to repo]
|
||||
- Telegram: @your_handle
|
||||
|
||||
---
|
||||
|
||||
*Cập nhật: 25/01/2026*
|
||||
@@ -49,7 +49,12 @@ export async function GET(request: NextRequest) {
|
||||
const brand = searchParams.get("brand");
|
||||
const search = searchParams.get("search");
|
||||
|
||||
const where: any = {};
|
||||
const where: {
|
||||
status?: string;
|
||||
category?: string;
|
||||
brand?: string;
|
||||
OR?: { name?: { contains: string }; model?: { contains: string }; serialNumber?: { contains: string } }[];
|
||||
} = {};
|
||||
if (status) where.status = status;
|
||||
if (category) where.category = category;
|
||||
if (brand) where.brand = brand;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { QRCodeGenerator } from "@/features/qr/QRCodeGenerator";
|
||||
import { getItem } from "../../actions";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Button } from "@/components/ui/primitives";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Printer, MapPin } from "lucide-react";
|
||||
import { ArrowLeft, MapPin } from "lucide-react";
|
||||
|
||||
// Fix: params is a Promise in Next.js 15+
|
||||
export default async function ItemQRPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { useAuthStore } from "@/store/useAuthStore";
|
||||
import { loginUser } from "@/app/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Input, Label, Card } from "@/components/ui/primitives"; // Simplified imports
|
||||
import { Button, Input, Label } from "@/components/ui/primitives"; // Simplified imports
|
||||
import { Loader2, Sparkles, ArrowRight, Lock, User as UserIcon } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
@@ -24,15 +24,16 @@ export default function LoginPage() {
|
||||
try {
|
||||
const res = await loginUser(username, password);
|
||||
if (res.success && res.user) {
|
||||
loginStore(res.user as any);
|
||||
loginStore(res.user as Parameters<typeof loginStore>[0]);
|
||||
toast("Đăng nhập thành công!", "success");
|
||||
router.push("/");
|
||||
} else {
|
||||
toast(res.error || "Đăng nhập thất bại", "error");
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Không xác định";
|
||||
console.error(err);
|
||||
toast("Lỗi kết nối: " + (err.message || "Không xác định"), "error");
|
||||
toast("Lỗi kết nối: " + errorMessage, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ export default async function Home() {
|
||||
{/* Header - Full Width but centered content */}
|
||||
<div className="bg-white/80 dark:bg-gray-900/80 sticky top-0 z-40 backdrop-blur-md border-b border-primary-100 dark:border-gray-700 shadow-sm">
|
||||
<div className="container mx-auto px-3 py-3 sm:px-4 sm:py-4 flex items-center justify-between gap-3 max-w-5xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary-500/20">
|
||||
<Box className="h-6 w-6" />
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="h-9 w-9 sm:h-10 sm:w-10 bg-gradient-to-br from-primary-400 to-primary-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary-500/20 shrink-0">
|
||||
<Box className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 dark:text-gray-100">TechGadget Manager</h1>
|
||||
<p className="text-xs text-primary-600 dark:text-primary-400 font-medium">Quản lý kho đồ công nghệ cá nhân</p>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg sm:text-xl font-bold tracking-tight text-gray-900 dark:text-gray-100 truncate">TechGadget</h1>
|
||||
<p className="text-[10px] sm:text-xs text-primary-600 dark:text-primary-400 font-medium truncate max-w-[150px] sm:max-w-none">Quản lý kho cá nhân</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,22 +14,24 @@ import { AuditLogViewer } from "@/features/audit/AuditLogViewer";
|
||||
// --- Item Type Manager Component ---
|
||||
function ItemTypeManager() {
|
||||
const { toast } = useToast();
|
||||
const [itemTypes, setItemTypes] = useState<any[]>([]);
|
||||
const [itemTypes, setItemTypes] = useState<{ id: string; value: string; label: string }[]>([]);
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadItemTypes();
|
||||
}, []);
|
||||
|
||||
const loadItemTypes = async () => {
|
||||
try {
|
||||
const res = await getItemTypes();
|
||||
setItemTypes(res);
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadItemTypes();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newValue || !newLabel) return;
|
||||
@@ -88,7 +90,7 @@ function ItemTypeManager() {
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<Label className="text-xs uppercase font-bold text-gray-500 mb-2 block">Loại tùy chỉnh của bạn</Label>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-1">
|
||||
{itemTypes.map((t: any) => (
|
||||
{itemTypes.map((t: { id: string; value: string; label: string }) => (
|
||||
<div key={t.id} className="group flex items-center gap-1 bg-white border border-gray-200 px-2 py-1 rounded-md text-sm shadow-sm hover:border-green-300 transition-colors">
|
||||
<span className="font-medium">{t.label}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono">({t.value})</span>
|
||||
@@ -129,54 +131,60 @@ export default function SettingsPage() {
|
||||
|
||||
// Brand State
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [brandsList, setBrandsList] = useState<any[]>([]);
|
||||
const [brandsList, setBrandsList] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
// API & System State
|
||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<{ id: string; name: string; key: string; createdAt: Date; lastUsed: Date | null }[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [clearBeforeImport, setClearBeforeImport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadBrands();
|
||||
loadApiKeys();
|
||||
}, []);
|
||||
|
||||
const loadBrands = async () => {
|
||||
try {
|
||||
const res = await getBrands();
|
||||
setBrandsList(res);
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
const res = await getApiKeys();
|
||||
setApiKeys(res);
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBrands();
|
||||
loadApiKeys();
|
||||
}, []);
|
||||
|
||||
// ... inside component
|
||||
|
||||
// Template State
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string; config: string }[]>([]);
|
||||
const [newTemplateName, setNewTemplateName] = useState("");
|
||||
// Visual Editor State
|
||||
const [tempType, setTempType] = useState(ITEM_TYPES[0].value);
|
||||
const [tempBrand, setTempBrand] = useState("");
|
||||
const [tempSpecs, setTempSpecs] = useState<{ key: string, value: string }[]>([{ key: "", value: "" }]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const res = await getTemplates();
|
||||
setTemplates(res);
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// Silently ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const handleAddSpecRow = () => {
|
||||
setTempSpecs([...tempSpecs, { key: "", value: "" }]);
|
||||
};
|
||||
@@ -678,7 +686,7 @@ export default function SettingsPage() {
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<Label className="text-xs uppercase font-bold text-gray-500 mb-3 block">Danh sách hãng hiện có</Label>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-1">
|
||||
{brandsList.map((b: any) => (
|
||||
{brandsList.map((b) => (
|
||||
<div key={b.id} className="group flex items-center gap-1 bg-white border border-gray-200 px-2 py-1 rounded-md text-sm shadow-sm hover:border-orange-300 transition-colors">
|
||||
<span>{b.name}</span>
|
||||
</div>
|
||||
@@ -768,7 +776,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{apiKeys.map((k: any) => (
|
||||
{apiKeys.map((k) => (
|
||||
<div key={k.id} className="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-bold text-sm text-gray-800">{k.name}</span>
|
||||
|
||||
@@ -43,16 +43,16 @@ export default function UserMenu() {
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex items-center gap-3 pl-1 pr-3 py-1 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-primary-100/50 dark:border-gray-700 hover:border-primary-200 dark:hover:border-gray-600 rounded-full transition-all hover:shadow-md shadow-sm"
|
||||
className="group flex items-center gap-1 sm:gap-3 pl-1 pr-1.5 sm:pr-3 py-1 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-primary-100/50 dark:border-gray-700 hover:border-primary-200 dark:hover:border-gray-600 rounded-full transition-all hover:shadow-md shadow-sm"
|
||||
>
|
||||
<div className="h-9 w-9 bg-gradient-to-br from-primary-500 to-primary-600 rounded-full flex items-center justify-center text-white font-bold text-sm shadow-inner ring-2 ring-white dark:ring-gray-800 overflow-hidden">
|
||||
<div className="h-8 w-8 sm:h-9 sm:w-9 bg-gradient-to-br from-primary-500 to-primary-600 rounded-full flex items-center justify-center text-white font-bold text-xs sm:text-sm shadow-inner ring-2 ring-white dark:ring-gray-800 overflow-hidden shrink-0">
|
||||
<AvatarImage />
|
||||
</div>
|
||||
<div className="flex flex-col items-start mr-1">
|
||||
<div className="hidden sm:flex flex-col items-start mr-1">
|
||||
<span className="text-xs font-semibold text-gray-700 dark:text-gray-200 leading-tight max-w-[100px] truncate">{displayName}</span>
|
||||
<span className="text-[10px] text-primary-600 dark:text-primary-400 font-medium leading-none">Admin</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 shrink-0 ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -330,20 +330,22 @@ export default function InventoryManager({ initialItems, locations }: { initialI
|
||||
</div>
|
||||
|
||||
{/* View Toggle & Count */}
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 border-l-4 border-primary-500 pl-3">Danh sách thiết bị</h2>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-2">
|
||||
<div className="flex items-center justify-between sm:justify-start gap-4">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100 border-l-4 border-primary-500 pl-3 whitespace-nowrap">Danh sách thiết bị</h2>
|
||||
<div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button onClick={() => setViewMode('grid')} className={`p-1.5 rounded-md transition-all ${viewMode === 'grid' ? 'bg-white dark:bg-gray-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}><LayoutGrid className="w-4 h-4" /></button>
|
||||
<button onClick={() => setViewMode('list')} className={`p-1.5 rounded-md transition-all ${viewMode === 'list' ? 'bg-white dark:bg-gray-700 shadow-sm text-primary-600 dark:text-primary-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}><List className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select className="h-8 text-xs w-40 bg-white dark:bg-gray-800 dark:text-gray-100" onChange={(e) => { if (e.target.value) setViewLocation(locations.find(l => l.id === e.target.value)); }}>
|
||||
<div className="flex gap-2 items-center justify-between sm:justify-end">
|
||||
<Select className="h-9 text-xs w-full sm:w-40 bg-white dark:bg-gray-800 dark:text-gray-100 rounded-xl" onChange={(e) => { if (e.target.value) setViewLocation(locations.find(l => l.id === e.target.value)); }}>
|
||||
<option value="">Quản lý túi đồ...</option>
|
||||
{locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</Select>
|
||||
<span className="text-xs font-medium text-primary-600 bg-primary-50 px-3 py-1 rounded-full border border-primary-100">{filteredItems.length} kết quả</span>
|
||||
<span className="text-[10px] sm:text-xs font-bold text-primary-600 bg-primary-50 dark:bg-primary-900/30 px-3 py-1.5 rounded-full border border-primary-100 dark:border-primary-800 whitespace-nowrap shadow-sm">
|
||||
{filteredItems.length} kết quả
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -339,15 +339,15 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-0 ring-1 ring-primary-100 dark:ring-gray-700 bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between border-b border-primary-50">
|
||||
<Card className="shadow-lg border-0 ring-1 ring-primary-100 dark:ring-gray-800 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between border-b border-primary-50 dark:border-gray-800/50">
|
||||
<CardTitle className="flex items-center gap-2 text-primary-600">
|
||||
<Wand2 className="h-5 w-5" />
|
||||
Thêm thiết bị mới
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Quantity Input */}
|
||||
<div className="flex items-center bg-gray-50 border border-gray-200 rounded-lg px-2 h-8">
|
||||
<div className="flex items-center bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg px-2 h-8">
|
||||
<Copy className="h-3 w-3 text-gray-500 mr-2" />
|
||||
<span className="text-xs text-gray-500 mr-2 font-medium">Số lượng:</span>
|
||||
<input
|
||||
@@ -356,10 +356,10 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
max="50"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(parseInt(e.target.value) || 1)}
|
||||
className="w-8 bg-transparent text-sm font-bold text-center border-none focus:ring-0 p-0"
|
||||
className="w-8 bg-transparent text-sm font-bold text-center border-none focus:ring-0 p-0 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" onClick={handleReset} className="h-8 w-8 p-0 text-primary-400 hover:text-primary-600 hover:bg-primary-50" title="Nhập lại từ đầu">
|
||||
<Button type="button" variant="ghost" onClick={handleReset} className="h-8 w-8 p-0 text-primary-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30" title="Nhập lại từ đầu">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -368,9 +368,9 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* Template Quick Select */}
|
||||
<div className="bg-primary-50/50 p-1.5 rounded-xl border border-primary-100 flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-primary-500 uppercase px-2 whitespace-nowrap">⚡ Mẫu nhanh</span>
|
||||
<Select onChange={handleTemplateChange} className="bg-transparent border-0 h-8 text-sm focus:ring-0 text-gray-700 font-medium flex-1">
|
||||
<div className="bg-primary-50/50 dark:bg-primary-900/20 p-1.5 rounded-xl border border-primary-100 dark:border-primary-900/40 flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-primary-500 dark:text-primary-400 uppercase px-2 whitespace-nowrap">⚡ Mẫu nhanh</span>
|
||||
<Select onChange={handleTemplateChange} className="bg-transparent border-0 h-8 text-sm focus:ring-0 text-gray-700 dark:text-gray-300 font-medium flex-1">
|
||||
<option value="">-- Chọn để điền tự động --</option>
|
||||
{VN_TEMPLATES.map((t) => (
|
||||
<option key={t.label} value={t.label}>{t.label}</option>
|
||||
@@ -381,7 +381,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
))}
|
||||
</optgroup>}
|
||||
</Select>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs text-primary-600 hover:bg-primary-100 px-2" onClick={handleSaveAsTemplate} title="Lưu cấu hình hiện tại thành mẫu mới">
|
||||
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs text-primary-600 dark:text-primary-400 hover:bg-primary-100 dark:hover:bg-primary-900/40 px-2" onClick={handleSaveAsTemplate} title="Lưu cấu hình hiện tại thành mẫu mới">
|
||||
<Save className="h-3.5 w-3.5 mr-1" /> Lưu mẫu
|
||||
</Button>
|
||||
</div>
|
||||
@@ -393,7 +393,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
|
||||
{/* Image Upload Area */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`h-16 w-16 rounded-xl border-2 border-dashed flex items-center justify-center overflow-hidden bg-gray-50 ${imgPreview ? 'border-primary-500' : 'border-gray-200'}`}>
|
||||
<div className={`h-16 w-16 rounded-xl border-2 border-dashed flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-gray-800 ${imgPreview ? 'border-primary-500' : 'border-gray-200 dark:border-gray-700'}`}>
|
||||
{imgPreview ? (
|
||||
<img src={imgPreview} alt="Preview" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -434,7 +434,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openType}
|
||||
className="w-full justify-between bg-white h-10 font-normal px-3 border-gray-200"
|
||||
className="w-full justify-between bg-white dark:bg-gray-800 h-10 font-normal px-3 border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span className="truncate">
|
||||
{watchedType
|
||||
@@ -508,7 +508,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2 lg:col-span-1">
|
||||
<Label>Trạng thái</Label>
|
||||
<Select {...form.register("status")} className="bg-white focus:border-primary-500 h-10">
|
||||
<Select {...form.register("status")} className="bg-white dark:bg-gray-800 focus:border-primary-500 h-10 border-gray-200 dark:border-gray-700">
|
||||
<option value="Available">Sẵn sàng</option>
|
||||
<option value="InUse">Đang dùng</option>
|
||||
<option value="Lent">Cho mượn</option>
|
||||
@@ -530,7 +530,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openLocation}
|
||||
className="w-full justify-between bg-white h-10 font-normal px-3 border-gray-200"
|
||||
className="w-full justify-between bg-white dark:bg-gray-800 h-10 font-normal px-3 border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span className="truncate">
|
||||
{form.watch("locationId")
|
||||
@@ -581,16 +581,16 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
</div>
|
||||
|
||||
{/* Dynamic Specs Section - Richer */}
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200/60 shadow-inner">
|
||||
<div className="bg-slate-50 dark:bg-gray-950/20 p-4 rounded-xl border border-slate-200/60 dark:border-gray-800 shadow-inner">
|
||||
{watchedType === 'Cable' ? (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase font-bold text-slate-500 border-b border-slate-200 pb-2 mb-2 flex items-center gap-2">
|
||||
<Label className="text-xs uppercase font-bold text-slate-500 dark:text-gray-400 border-b border-slate-200 dark:border-gray-800 pb-2 mb-2 flex items-center gap-2">
|
||||
<Zap className="h-3 w-3 text-yellow-500" /> Thông số Kỹ thuật Cáp
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Chức năng</Label>
|
||||
<Select {...form.register("specs.cableType")} className="h-9 text-sm bg-white">
|
||||
<Select {...form.register("specs.cableType")} className="h-9 text-sm bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700">
|
||||
<option value="All">Sạc + Dữ Liệu (Full)</option>
|
||||
<option value="Charging">Chuyên Sạc (Charging Only)</option>
|
||||
<option value="Data">Chuyên Dữ Liệu</option>
|
||||
@@ -598,7 +598,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Độ dài</Label>
|
||||
<Input {...form.register("specs.length")} placeholder="1m..." className="h-9 bg-white" />
|
||||
<Input {...form.register("specs.length")} placeholder="1m..." className="h-9 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Chuẩn kết nối</Label>
|
||||
@@ -617,7 +617,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
value={form.watch('specs.bandwidth') || ''}
|
||||
onValueChange={(v) => form.setValue('specs.bandwidth', v)}
|
||||
placeholder="40 Gbps..."
|
||||
className="h-9 bg-white"
|
||||
className="h-9 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
@@ -627,14 +627,14 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
value={form.watch('specs.power') || ''}
|
||||
onValueChange={(v) => form.setValue('specs.power', v)}
|
||||
placeholder="100W..."
|
||||
className="h-9 bg-white"
|
||||
className="h-9 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : watchedType === 'Storage' ? (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase font-bold text-slate-500 border-b border-slate-200 pb-2 mb-2 flex items-center gap-2">
|
||||
<Label className="text-xs uppercase font-bold text-slate-500 dark:text-gray-400 border-b border-slate-200 dark:border-gray-800 pb-2 mb-2 flex items-center gap-2">
|
||||
<Box className="h-3 w-3 text-purple-500" /> Thông số Lưu trữ
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -645,7 +645,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
value={form.watch('specs.capacity') || ''}
|
||||
onValueChange={(v) => form.setValue('specs.capacity', v)}
|
||||
placeholder="1 TB..."
|
||||
className="h-9 bg-white"
|
||||
className="h-9 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -662,7 +662,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase font-bold text-slate-500 border-b border-slate-200 pb-2 mb-2 flex items-center gap-2">
|
||||
<Label className="text-xs uppercase font-bold text-slate-500 dark:text-gray-400 border-b border-slate-200 dark:border-gray-800 pb-2 mb-2 flex items-center gap-2">
|
||||
<Tag className="h-3 w-3" /> Thông số chung
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -673,12 +673,12 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
value={form.watch('specs.power') || ''}
|
||||
onValueChange={(v) => form.setValue('specs.power', v)}
|
||||
placeholder="Nhập thông số..."
|
||||
className="h-9 bg-white"
|
||||
className="h-9 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Tính năng khác</Label>
|
||||
<Input {...form.register("specs.other")} placeholder="..." className="h-9 bg-white" />
|
||||
<Input {...form.register("specs.other")} placeholder="..." className="h-9 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -713,7 +713,7 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
onChange={(e) => setWarrantyMonths(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
className="h-9 flex-1 bg-white text-xs"
|
||||
className="h-9 flex-1 bg-white dark:bg-gray-800 text-xs border-gray-200 dark:border-gray-700"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setWarrantyMonths(e.target.value);
|
||||
}}
|
||||
@@ -748,14 +748,14 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
<div className="space-y-2">
|
||||
{Object.entries(form.watch('specs') || {}).filter(([k]) => !['capacity', 'interface', 'bandwidth', 'power', 'ports', 'cableType', 'length', 'connectivity', 'dpi', 'switch', 'type', 'feature', 'resolution', 'size', 'panel', 'other'].includes(k)).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2 items-center">
|
||||
<div className="w-1/3 bg-gray-100 px-2 py-1.5 rounded text-xs border truncate font-medium text-gray-600">{k}</div>
|
||||
<div className="w-1/3 bg-gray-100 dark:bg-gray-800 px-2 py-1.5 rounded text-xs border border-gray-200 dark:border-gray-700 truncate font-medium text-gray-600 dark:text-gray-400">{k}</div>
|
||||
<Input
|
||||
value={v as string}
|
||||
onChange={(e) => {
|
||||
const current = form.getValues('specs');
|
||||
form.setValue('specs', { ...current, [k]: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs flex-1 bg-white"
|
||||
className="h-8 text-xs flex-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => {
|
||||
const current = form.getValues('specs');
|
||||
|
||||
@@ -177,7 +177,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
return (
|
||||
<div className="select-none">
|
||||
<div
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-colors ${isSelected ? 'bg-primary-100 text-primary-900 font-bold' : 'hover:bg-primary-50/50 text-gray-700'}`}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-colors ${isSelected ? 'bg-primary-100 dark:bg-primary-900/40 text-primary-900 dark:text-primary-100 font-bold' : 'hover:bg-primary-50/50 dark:hover:bg-primary-900/20 text-gray-700 dark:text-gray-300'}`}
|
||||
style={{ marginLeft: `${level * 16}px` }}
|
||||
onClick={() => {
|
||||
setSelectedLocationId(node.id);
|
||||
@@ -203,13 +203,13 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
|
||||
{/* Item Count Badge */}
|
||||
{node._count && node._count.items > 0 && (
|
||||
<span className="bg-primary-200 text-primary-800 text-[10px] px-1.5 py-0.5 rounded-full font-bold">
|
||||
<span className="bg-primary-200 dark:bg-primary-800 text-primary-800 dark:text-primary-100 text-[10px] px-1.5 py-0.5 rounded-full font-bold">
|
||||
{node._count.items}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="border-l border-primary-100 ml-[11px]">
|
||||
<div className="border-l border-primary-100 dark:border-primary-900/50 ml-[11px]">
|
||||
{node.children.map(child => <LocationTreeItem key={child.id} node={child} level={level + 1} />)}
|
||||
</div>
|
||||
)}
|
||||
@@ -244,9 +244,9 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
)}
|
||||
|
||||
{/* Tree View */}
|
||||
<Card className="w-full md:col-span-4 border-primary-100 shadow-sm flex flex-col h-[350px] md:h-full bg-white">
|
||||
<CardHeader className="pb-2 border-b border-primary-50 bg-primary-50/30">
|
||||
<CardTitle className="text-base text-primary-700 flex justify-between items-center">
|
||||
<Card className="w-full md:col-span-4 border-primary-100 dark:border-gray-800 shadow-sm flex flex-col h-[350px] md:h-full bg-white dark:bg-gray-900">
|
||||
<CardHeader className="pb-2 border-b border-primary-50 dark:border-gray-800 bg-primary-50/30 dark:bg-gray-800/20">
|
||||
<CardTitle className="text-base text-primary-700 dark:text-primary-400 flex justify-between items-center">
|
||||
<span>Danh sách vị trí</span>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => router.refresh()}> <RefreshCw size={14} /> </Button>
|
||||
@@ -265,31 +265,31 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto pt-2 pl-2">
|
||||
{initialLocations.map(node => <LocationTreeItem key={node.id} node={node} />)}
|
||||
{initialLocations.length === 0 && <p className="text-sm text-gray-400 text-center py-10">Chưa có vị trí nào</p>}
|
||||
{initialLocations.length === 0 && <p className="text-sm text-gray-400 dark:text-gray-500 text-center py-10">Chưa có vị trí nào</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Details & Actions Panel */}
|
||||
<Card className="w-full md:col-span-8 border-primary-100 shadow-sm overflow-hidden flex flex-col bg-white min-h-[400px] md:h-full">
|
||||
<Card className="w-full md:col-span-8 border-primary-100 dark:border-gray-800 shadow-sm overflow-hidden flex flex-col bg-white dark:bg-gray-900 min-h-[400px] md:h-full">
|
||||
{!selectedLocationId && !isCreating ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-300">
|
||||
<MapPin className="h-16 w-16 mb-4 opacity-20 text-primary-500" />
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-300 dark:text-gray-700">
|
||||
<MapPin className="h-16 w-16 mb-4 opacity-20 text-primary-500 dark:text-primary-400" />
|
||||
<p>Chọn vị trí để xem kho</p>
|
||||
</div>
|
||||
) : (isCreating || isEditing) ? (
|
||||
<div className="p-4 md:p-8 max-w-lg mx-auto w-full">
|
||||
<h3 className="font-bold text-lg text-primary-800 mb-6 flex items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-primary-800 dark:text-primary-400 mb-6 flex items-center gap-2">
|
||||
{isEditing ? <Pencil className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
|
||||
{isEditing ? "Chỉnh sửa vị trí" : (selectedLocationId && !isEditing ? "Thêm vị trí con" : "Tạo vị trí gốc mới")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Tên vị trí</Label>
|
||||
<Input value={newLocName} onChange={e => setNewLocName(e.target.value)} placeholder="Tên phòng, Tủ, Balo..." className="border-primary-200" />
|
||||
<Input value={newLocName} onChange={e => setNewLocName(e.target.value)} placeholder="Tên phòng, Tủ, Balo..." className="border-primary-200 dark:border-gray-700 dark:bg-gray-800" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Select value={newLocType} onChange={e => setNewLocType(e.target.value)} className="border-primary-200">
|
||||
<Select value={newLocType} onChange={e => setNewLocType(e.target.value)} className="border-primary-200 dark:border-gray-700 dark:bg-gray-800">
|
||||
<option value="Fixed">Cố định (Phòng/Nhà)</option>
|
||||
<option value="Container">Túi/Hộp (Di động)</option>
|
||||
<option value="Person">Người (Cho mượn)</option>
|
||||
@@ -300,7 +300,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
<IconSelect
|
||||
value={newLocIcon}
|
||||
onValueChange={setNewLocIcon}
|
||||
className="border-primary-200"
|
||||
className="border-primary-200 dark:border-gray-700 dark:bg-gray-800"
|
||||
groups={LOCATION_ICON_GROUPS}
|
||||
iconMap={LOCATION_ICONS}
|
||||
/>
|
||||
@@ -310,7 +310,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
<Select
|
||||
value={newLocParentId || ""}
|
||||
onChange={e => setNewLocParentId(e.target.value || null)}
|
||||
className="border-primary-200"
|
||||
className="border-primary-200 dark:border-gray-700 dark:bg-gray-800"
|
||||
disabled={!isEditing && !!selectedLocationId && !isCreating} // If creating child, parent is fixed initially but let's allow changing
|
||||
>
|
||||
<option value="">-- Gốc (Root) --</option>
|
||||
@@ -325,19 +325,19 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={isEditing ? handleUpdate : handleCreate} className="flex-1 bg-primary-500 hover:bg-primary-600 text-white font-bold shadow-lg shadow-primary-500/20">
|
||||
<Button onClick={isEditing ? handleUpdate : handleCreate} className="flex-1 bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-700 text-white font-bold shadow-lg shadow-primary-500/20">
|
||||
{isEditing ? "Cập nhật" : "Lưu vị trí"}
|
||||
</Button>
|
||||
<Button onClick={() => { setIsCreating(false); setIsEditing(false); }} variant="outline" className="flex-1 border-primary-200 text-primary-600 hover:bg-primary-50">Hủy</Button>
|
||||
<Button onClick={() => { setIsCreating(false); setIsEditing(false); }} variant="outline" className="flex-1 border-primary-200 dark:border-gray-700 text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/40">Hủy</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{/* Detail Header */}
|
||||
<div className="px-6 py-4 border-b border-primary-100 bg-primary-50/30 flex justify-between items-start">
|
||||
<div className="px-6 py-4 border-b border-primary-100 dark:border-gray-800 bg-primary-50/30 dark:bg-gray-800/40 flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{(() => {
|
||||
if (selectedLocation?.icon) {
|
||||
const { icon: Icon, color } = LOCATION_ICONS[selectedLocation.icon] || LOCATION_ICONS['default'] || ITEM_ICONS['default'];
|
||||
@@ -347,8 +347,8 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
})()}
|
||||
{selectedLocation?.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-xs">{selectedLocation?.type}</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2">
|
||||
<span className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-0.5 rounded text-xs">{selectedLocation?.type}</span>
|
||||
<span>•</span>
|
||||
<span>ID: {selectedLocation?.id.slice(0, 8)}</span>
|
||||
</p>
|
||||
@@ -360,7 +360,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
setNewLocIcon(selectedLocation?.icon || "");
|
||||
setNewLocParentId(selectedLocation?.parentId || null);
|
||||
setIsEditing(true);
|
||||
}} className="bg-white border border-primary-200 text-primary-700 hover:bg-primary-50 gap-1">
|
||||
}} className="bg-white dark:bg-gray-800 border border-primary-200 dark:border-gray-700 text-primary-700 dark:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20 gap-1">
|
||||
<Pencil size={14} /> Sửa
|
||||
</Button>
|
||||
<Button size="sm" onClick={async () => {
|
||||
@@ -372,7 +372,7 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
const items = await getAllItems();
|
||||
setAllItems(items);
|
||||
setIsManageOpen(true);
|
||||
}} className="bg-primary-500 text-white hover:bg-primary-600 gap-1 shadow-sm">
|
||||
}} className="bg-primary-500 dark:bg-primary-600 text-white hover:bg-primary-600 dark:hover:bg-primary-700 gap-1 shadow-sm">
|
||||
<Package size={14} /> Quản lý túi đồ
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => {
|
||||
@@ -380,23 +380,23 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
setNewLocName("");
|
||||
setNewLocIcon("");
|
||||
setNewLocParentId(selectedLocationId); // Set current as parent for new child
|
||||
}} className="bg-white border border-primary-200 text-primary-700 hover:bg-primary-50 gap-1">
|
||||
}} className="bg-white dark:bg-gray-800 border border-primary-200 dark:border-gray-700 text-primary-700 dark:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20 gap-1">
|
||||
<Plus size={14} /> Thêm con
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(selectedLocationId!)} className="text-red-400 hover:text-red-600 hover:bg-red-50">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(selectedLocationId!)} className="text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="flex-1 overflow-auto bg-slate-50 p-6">
|
||||
<h3 className="text-sm font-bold text-gray-500 uppercase mb-4 flex justify-between">
|
||||
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-gray-950/40 p-6">
|
||||
<h3 className="text-sm font-bold text-gray-500 dark:text-gray-400 uppercase mb-4 flex justify-between">
|
||||
<span>📦 Thiết bị tại đây ({locationItems.length})</span>
|
||||
</h3>
|
||||
|
||||
{isLoadingItems ? (
|
||||
<div className="text-center py-10 text-gray-400 animate-pulse">Đang tải danh sách...</div>
|
||||
<div className="text-center py-10 text-gray-400 dark:text-gray-600 animate-pulse">Đang tải danh sách...</div>
|
||||
) : locationItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{locationItems.map(item => {
|
||||
@@ -404,15 +404,15 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white p-3 rounded-xl border border-gray-200 shadow-sm flex items-center gap-3 hover:shadow-md transition-shadow cursor-pointer hover:border-primary-300"
|
||||
className="bg-white dark:bg-gray-800 p-3 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm flex items-center gap-3 hover:shadow-md transition-shadow cursor-pointer hover:border-primary-300 dark:hover:border-primary-700"
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
>
|
||||
<div className={`h-10 w-10 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${item.image ? 'bg-gray-100' : bg}`}>
|
||||
<div className={`h-10 w-10 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${item.image ? 'bg-gray-100 dark:bg-gray-700' : bg}`}>
|
||||
{item.image ? <img src={item.image} className="w-full h-full object-cover" /> : <ItemIcon className={`h-5 w-5 ${color}`} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate" title={item.name}>{item.name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{item.brand || "No Brand"} • {item.model || ""}</div>
|
||||
<div className="font-medium text-sm truncate text-gray-900 dark:text-gray-100" title={item.name}>{item.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{item.brand || "No Brand"} • {item.model || ""}</div>
|
||||
</div>
|
||||
<div className={`w-2 h-2 rounded-full ${item.status === 'Available' ? 'bg-green-500' : 'bg-primary-500'}`} />
|
||||
</div>
|
||||
@@ -420,9 +420,9 @@ export function LocationManager({ initialLocations }: { initialLocations: Locati
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 border-2 border-dashed border-gray-200 rounded-2xl bg-white/50">
|
||||
<Package className="h-10 w-10 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-gray-400 text-sm">Chưa có thiết bị nào trực tiếp ở đây.</p>
|
||||
<div className="text-center py-12 border-2 border-dashed border-gray-200 dark:border-gray-800 rounded-2xl bg-white/50 dark:bg-gray-900/50">
|
||||
<Package className="h-10 w-10 mx-auto text-gray-300 dark:text-gray-700 mb-2" />
|
||||
<p className="text-gray-400 dark:text-gray-600 text-sm">Chưa có thiết bị nào trực tiếp ở đây.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,46 +7,23 @@ const globalForPrisma = globalThis as unknown as {
|
||||
};
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
// CASE 1: DATABASE_URL is set (Docker / External DB)
|
||||
// Use Prisma's default behavior which reads from env var
|
||||
// CASE 1: DATABASE_URL is set (Docker / External DB) - ALWAYS preferred
|
||||
if (process.env.DATABASE_URL) {
|
||||
console.log("[DB] Using DATABASE_URL from environment");
|
||||
return new PrismaClient();
|
||||
}
|
||||
|
||||
// CASE 2: Production without DATABASE_URL (Vercel /tmp strategy)
|
||||
// CASE 2: Production WITHOUT DATABASE_URL = ERROR
|
||||
// This prevents accidentally using dev.db in production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
try {
|
||||
const dbName = "dev.db";
|
||||
const dbPath = path.join(process.cwd(), "prisma", dbName);
|
||||
const tmpDbPath = path.join("/tmp", dbName);
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.copyFileSync(dbPath, tmpDbPath);
|
||||
console.log("[DB] Copied to /tmp for Vercel");
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Failed to copy db: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`[DB] Source database not found at ${dbPath}`);
|
||||
}
|
||||
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: `file:${tmpDbPath}`
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DB] Initialization error:", error);
|
||||
return new PrismaClient();
|
||||
}
|
||||
console.error("[DB] ⛔ CRITICAL ERROR: DATABASE_URL is required in production!");
|
||||
console.error("[DB] Please set DATABASE_URL environment variable.");
|
||||
console.error("[DB] Example: DATABASE_URL=file:/app/db/prod.db");
|
||||
throw new Error("DATABASE_URL is required in production environment. Set it in docker-compose.yml or environment variables.");
|
||||
}
|
||||
|
||||
// CASE 3: Development
|
||||
console.log("[DB] Development mode");
|
||||
// CASE 3: Development - use default from schema.prisma (prisma/dev.db)
|
||||
console.log("[DB] Development mode - using default database");
|
||||
return new PrismaClient();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user