initial
This commit is contained in:
commit
4a6decc70e
55 changed files with 18446 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# API Configuration
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:8080/api/v1
|
||||||
|
|
||||||
|
# Development Configuration
|
||||||
|
VITE_DEV_MODE=true
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
---
|
||||||
|
|
||||||
|
Default to using Bun instead of Node.js.
|
||||||
|
|
||||||
|
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||||
|
- Use `bun test` instead of `jest` or `vitest`
|
||||||
|
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||||
|
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||||
|
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||||
|
- Bun automatically loads .env, so don't use dotenv.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||||
|
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||||
|
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||||
|
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||||
|
- `WebSocket` is built-in. Don't use `ws`.
|
||||||
|
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||||
|
- Bun.$`ls` instead of execa.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use `bun test` to run tests.
|
||||||
|
|
||||||
|
```ts#index.test.ts
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
|
||||||
|
test("hello world", () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
```ts#index.ts
|
||||||
|
import index from "./index.html"
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
"/": index,
|
||||||
|
"/api/users/:id": {
|
||||||
|
GET: (req) => {
|
||||||
|
return new Response(JSON.stringify({ id: req.params.id }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// optional websocket support
|
||||||
|
websocket: {
|
||||||
|
open: (ws) => {
|
||||||
|
ws.send("Hello, world!");
|
||||||
|
},
|
||||||
|
message: (ws, message) => {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
close: (ws) => {
|
||||||
|
// handle close
|
||||||
|
}
|
||||||
|
},
|
||||||
|
development: {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||||
|
|
||||||
|
```html#index.html
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
<script type="module" src="./frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
With the following `frontend.tsx`:
|
||||||
|
|
||||||
|
```tsx#frontend.tsx
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// import .css files directly and it works
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
const root = createRoot(document.body);
|
||||||
|
|
||||||
|
export default function Frontend() {
|
||||||
|
return <h1>Hello, world!</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.render(<Frontend />);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run index.ts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun --hot ./index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||||
67
DEBUG_INSTRUCTIONS.md
Normal file
67
DEBUG_INSTRUCTIONS.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# 備品編集フォーム修正完了
|
||||||
|
|
||||||
|
## 修正内容
|
||||||
|
|
||||||
|
### 問題
|
||||||
|
備品編集フォームで既存のデータが表示されない問題
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
React Hook FormとHeroUIコンポーネントの組み合わせで、`reset()`が正しく動作しない
|
||||||
|
|
||||||
|
### 解決策
|
||||||
|
|
||||||
|
1. **`reset()`の代わりに`setValue()`を使用**
|
||||||
|
- 各フィールドに個別に値を設定
|
||||||
|
- より確実なフォーム値の反映
|
||||||
|
|
||||||
|
2. **明示的な`value`プロパティの設定**
|
||||||
|
- HeroUIコンポーネントに`value`を明示的に渡す
|
||||||
|
- `watch()`で取得した現在の値を使用
|
||||||
|
|
||||||
|
3. **Controller使用時の改善**
|
||||||
|
- Switch、Selectコンポーネントで明示的な値設定
|
||||||
|
- onChange時に`setValue()`も同時実行
|
||||||
|
|
||||||
|
### 実装した変更
|
||||||
|
|
||||||
|
1. **フォーム値設定**:
|
||||||
|
```typescript
|
||||||
|
setValue('name', item.name || '')
|
||||||
|
setValue('label_id', item.label_id || '')
|
||||||
|
// すべてのフィールドに個別設定
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **明示的value設定**:
|
||||||
|
```tsx
|
||||||
|
<Input
|
||||||
|
{...register('name')}
|
||||||
|
value={formValues.name || ''}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **ControllerでのSwitch/Select**:
|
||||||
|
```tsx
|
||||||
|
<Switch
|
||||||
|
isSelected={formValues.is_disposed || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value)
|
||||||
|
setValue('is_disposed', value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## テスト方法
|
||||||
|
|
||||||
|
1. **開発サーバー起動**: `npm run dev`
|
||||||
|
2. **備品一覧アクセス**: http://localhost:5174/items
|
||||||
|
3. **編集テスト**: 既存備品の「編集」ボタンをクリック
|
||||||
|
4. **確認**: 全フィールドに既存データが表示される
|
||||||
|
|
||||||
|
## デバッグ情報
|
||||||
|
|
||||||
|
開発モードでコンソールに以下が出力されます:
|
||||||
|
- `ItemForm Debug: { ... }` - API取得状況
|
||||||
|
- `Form values: { ... }` - 現在のフォーム値
|
||||||
|
- `Setting form values with item data: { ... }` - 値設定処理
|
||||||
|
|
||||||
|
フォーム編集機能が正常に動作するはずです!
|
||||||
754
FRONTEND_SPEC.md
Normal file
754
FRONTEND_SPEC.md
Normal file
|
|
@ -0,0 +1,754 @@
|
||||||
|
# HyperDashi フロントエンド実装仕様書
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
HyperDashi物品管理システムのフロントエンドをPreact + TypeScript + MUI + Viteで実装する。
|
||||||
|
Excel風のUIを提供し、物品管理・貸出管理・画像アップロード機能を持つWebアプリケーションを構築する。
|
||||||
|
|
||||||
|
## 技術スタック
|
||||||
|
|
||||||
|
- **フレームワーク**: Preact 10.x
|
||||||
|
- **言語**: TypeScript 5.x
|
||||||
|
- **UIライブラリ**: MUI (Material-UI) 5.x
|
||||||
|
- **ビルドツール**: Vite 5.x
|
||||||
|
- **状態管理**: Preact/signals または Zustand
|
||||||
|
- **HTTP クライアント**: Fetch API または Axios
|
||||||
|
- **ルーティング**: Preact Router
|
||||||
|
- **フォームバリデーション**: React Hook Form + Yup/Zod
|
||||||
|
- **データテーブル**: MUI DataGrid または react-table
|
||||||
|
- **ファイルアップロード**: react-dropzone
|
||||||
|
|
||||||
|
## バックエンドAPI仕様
|
||||||
|
|
||||||
|
### ベースURL
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### 認証
|
||||||
|
現在は認証なし(将来的にJWTトークン認証を追加予定)
|
||||||
|
|
||||||
|
### エンドポイント一覧
|
||||||
|
|
||||||
|
#### 1. 基本エンドポイント
|
||||||
|
- `GET /` - サーバー情報
|
||||||
|
- `GET /health` - ヘルスチェック
|
||||||
|
|
||||||
|
#### 2. 物品管理API
|
||||||
|
|
||||||
|
**物品一覧取得**
|
||||||
|
```http
|
||||||
|
GET /api/v1/items?page=1&per_page=20&search=keywords&is_on_loan=false&is_disposed=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```typescript
|
||||||
|
interface ItemsListResponse {
|
||||||
|
items: Item[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
label_id: string;
|
||||||
|
model_number?: string;
|
||||||
|
remarks?: string;
|
||||||
|
purchase_year?: number;
|
||||||
|
purchase_amount?: number;
|
||||||
|
durability_years?: number;
|
||||||
|
is_depreciation_target?: boolean;
|
||||||
|
connection_names?: string[];
|
||||||
|
cable_color_pattern?: string[];
|
||||||
|
storage_locations?: string[];
|
||||||
|
is_on_loan?: boolean;
|
||||||
|
qr_code_type?: 'qr' | 'barcode' | 'none';
|
||||||
|
is_disposed?: boolean;
|
||||||
|
image_url?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**物品作成**
|
||||||
|
```http
|
||||||
|
POST /api/v1/items
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "テストマイク",
|
||||||
|
"label_id": "MIC001",
|
||||||
|
"model_number": "Sony WM-1000XM4",
|
||||||
|
"remarks": "高品質ワイヤレスマイク",
|
||||||
|
"purchase_year": 2023,
|
||||||
|
"purchase_amount": 35000.0,
|
||||||
|
"durability_years": 5,
|
||||||
|
"is_depreciation_target": true,
|
||||||
|
"connection_names": ["XLR", "USB-C"],
|
||||||
|
"cable_color_pattern": ["red", "black"],
|
||||||
|
"storage_locations": ["部屋A", "ラックB", "コンテナ1"],
|
||||||
|
"qr_code_type": "qr",
|
||||||
|
"image_url": "http://example.com/image.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**物品詳細取得**
|
||||||
|
```http
|
||||||
|
GET /api/v1/items/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**物品更新**
|
||||||
|
```http
|
||||||
|
PUT /api/v1/items/:id
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**物品削除**
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/items/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**物品廃棄**
|
||||||
|
```http
|
||||||
|
POST /api/v1/items/:id/dispose
|
||||||
|
```
|
||||||
|
|
||||||
|
**ラベルID検索**
|
||||||
|
```http
|
||||||
|
GET /api/v1/items/by-label/:label_id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 貸出管理API
|
||||||
|
|
||||||
|
**貸出一覧取得**
|
||||||
|
```http
|
||||||
|
GET /api/v1/loans?page=1&per_page=20&item_id=1&student_number=12345&active_only=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```typescript
|
||||||
|
interface LoansListResponse {
|
||||||
|
loans: LoanWithItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoanWithItem {
|
||||||
|
id: number;
|
||||||
|
item_id: number;
|
||||||
|
item_name: string;
|
||||||
|
item_label_id: string;
|
||||||
|
student_number: string;
|
||||||
|
student_name: string;
|
||||||
|
organization?: string;
|
||||||
|
loan_date: string;
|
||||||
|
return_date?: string;
|
||||||
|
remarks?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Loan {
|
||||||
|
id: number;
|
||||||
|
item_id: number;
|
||||||
|
student_number: string;
|
||||||
|
student_name: string;
|
||||||
|
organization?: string;
|
||||||
|
loan_date: string;
|
||||||
|
return_date?: string;
|
||||||
|
remarks?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**貸出作成**
|
||||||
|
```http
|
||||||
|
POST /api/v1/loans
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"item_id": 1,
|
||||||
|
"student_number": "12345678",
|
||||||
|
"student_name": "山田太郎",
|
||||||
|
"organization": "情報メディアシステム学類",
|
||||||
|
"remarks": "授業用"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**貸出詳細取得**
|
||||||
|
```http
|
||||||
|
GET /api/v1/loans/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**返却処理**
|
||||||
|
```http
|
||||||
|
POST /api/v1/loans/:id/return
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"return_date": "2024-01-15T10:30:00Z", // optional, defaults to now
|
||||||
|
"remarks": "正常に返却"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 画像管理API
|
||||||
|
|
||||||
|
**画像アップロード**
|
||||||
|
```http
|
||||||
|
POST /api/v1/images/upload
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
Form fields:
|
||||||
|
- image: File (JPEG, PNG, GIF, WebP, max 10MB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```typescript
|
||||||
|
interface ImageUploadResponse {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### エラーレスポンス
|
||||||
|
```typescript
|
||||||
|
interface ErrorResponse {
|
||||||
|
error: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI/UX要件
|
||||||
|
|
||||||
|
### 1. 全体レイアウト
|
||||||
|
|
||||||
|
**ヘッダー**
|
||||||
|
- アプリ名: "HyperDashi"
|
||||||
|
- ナビゲーションメニュー: 物品管理、貸出管理
|
||||||
|
- 検索バー(グローバル検索)
|
||||||
|
|
||||||
|
**サイドナビ**
|
||||||
|
- 物品管理
|
||||||
|
- 物品一覧
|
||||||
|
- 物品登録
|
||||||
|
- 廃棄物品
|
||||||
|
- 貸出管理
|
||||||
|
- 貸出一覧
|
||||||
|
- 新規貸出
|
||||||
|
- 返却処理
|
||||||
|
|
||||||
|
**メインコンテンツエリア**
|
||||||
|
- ページタイトル
|
||||||
|
- アクションボタン
|
||||||
|
- データテーブルまたはフォーム
|
||||||
|
|
||||||
|
### 2. 物品管理画面
|
||||||
|
|
||||||
|
#### 物品一覧画面 (`/items`)
|
||||||
|
|
||||||
|
**機能要件**
|
||||||
|
- Excel風のデータグリッド表示
|
||||||
|
- ページネーション(20件/ページ)
|
||||||
|
- 検索機能(物品名、ラベルID、型番、備考)
|
||||||
|
- フィルタリング(貸出状態、廃棄状態)
|
||||||
|
- ソート機能(各カラム)
|
||||||
|
- 行選択(単一・複数)
|
||||||
|
- アクション(編集、削除、廃棄、QRコード表示)
|
||||||
|
|
||||||
|
**表示カラム**
|
||||||
|
- ラベルID
|
||||||
|
- 物品名
|
||||||
|
- 型番
|
||||||
|
- 購入年度
|
||||||
|
- 購入金額
|
||||||
|
- 貸出状態(チップ表示)
|
||||||
|
- 廃棄状態(チップ表示)
|
||||||
|
- 最終更新日
|
||||||
|
- アクション(編集、削除ボタン)
|
||||||
|
|
||||||
|
**UI要素**
|
||||||
|
```tsx
|
||||||
|
// フィルター・検索バー
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="物品名、ラベルID、型番で検索"
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <SearchIcon />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={2}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>貸出状態</InputLabel>
|
||||||
|
<Select value={loanFilter} onChange={handleLoanFilterChange}>
|
||||||
|
<MenuItem value="">すべて</MenuItem>
|
||||||
|
<MenuItem value="available">貸出可能</MenuItem>
|
||||||
|
<MenuItem value="on_loan">貸出中</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={2}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>廃棄状態</InputLabel>
|
||||||
|
<Select value={disposalFilter} onChange={handleDisposalFilterChange}>
|
||||||
|
<MenuItem value="">すべて</MenuItem>
|
||||||
|
<MenuItem value="active">使用中</MenuItem>
|
||||||
|
<MenuItem value="disposed">廃棄済み</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAddNew}>
|
||||||
|
新規登録
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
// データグリッド
|
||||||
|
<DataGrid
|
||||||
|
rows={items}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={20}
|
||||||
|
rowsPerPageOptions={[10, 20, 50]}
|
||||||
|
checkboxSelection
|
||||||
|
disableSelectionOnClick
|
||||||
|
autoHeight
|
||||||
|
loading={loading}
|
||||||
|
components={{
|
||||||
|
Toolbar: GridToolbar,
|
||||||
|
}}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onSelectionModelChange={handleSelectionChange}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 物品登録・編集画面 (`/items/new`, `/items/:id/edit`)
|
||||||
|
|
||||||
|
**機能要件**
|
||||||
|
- 必須フィールドバリデーション
|
||||||
|
- リアルタイムバリデーション
|
||||||
|
- 画像アップロード(ドラッグ&ドロップ対応)
|
||||||
|
- 配列フィールド(接続名、ケーブル色、収納場所)の動的追加・削除
|
||||||
|
- フォーム状態の保存(一時保存機能)
|
||||||
|
|
||||||
|
**フォームフィールド**
|
||||||
|
```tsx
|
||||||
|
interface ItemFormData {
|
||||||
|
name: string; // 必須
|
||||||
|
label_id: string; // 必須、英数字のみ
|
||||||
|
model_number?: string;
|
||||||
|
remarks?: string;
|
||||||
|
purchase_year?: number; // 1900-2100
|
||||||
|
purchase_amount?: number; // 0以上
|
||||||
|
durability_years?: number; // 1-100
|
||||||
|
is_depreciation_target?: boolean;
|
||||||
|
connection_names?: string[]; // 動的配列
|
||||||
|
cable_color_pattern?: string[]; // 動的配列
|
||||||
|
storage_locations?: string[]; // 動的配列
|
||||||
|
qr_code_type?: 'qr' | 'barcode' | 'none';
|
||||||
|
image_url?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**バリデーションルール**
|
||||||
|
```typescript
|
||||||
|
const itemValidationSchema = yup.object({
|
||||||
|
name: yup.string().required('物品名は必須です').max(255, '255文字以内で入力してください'),
|
||||||
|
label_id: yup
|
||||||
|
.string()
|
||||||
|
.required('ラベルIDは必須です')
|
||||||
|
.matches(/^[A-Za-z0-9]+$/, '英数字のみ使用可能です')
|
||||||
|
.max(50, '50文字以内で入力してください'),
|
||||||
|
model_number: yup.string().max(255, '255文字以内で入力してください'),
|
||||||
|
purchase_year: yup
|
||||||
|
.number()
|
||||||
|
.min(1900, '1900年以降を入力してください')
|
||||||
|
.max(2100, '2100年以前を入力してください'),
|
||||||
|
purchase_amount: yup.number().min(0, '0以上の値を入力してください'),
|
||||||
|
durability_years: yup
|
||||||
|
.number()
|
||||||
|
.min(1, '1年以上を入力してください')
|
||||||
|
.max(100, '100年以下を入力してください'),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**画像アップロード UI**
|
||||||
|
```tsx
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>物品画像</Typography>
|
||||||
|
<Dropzone
|
||||||
|
accept={{
|
||||||
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
|
'image/png': ['.png'],
|
||||||
|
'image/gif': ['.gif'],
|
||||||
|
'image/webp': ['.webp']
|
||||||
|
}}
|
||||||
|
maxSize={10 * 1024 * 1024} // 10MB
|
||||||
|
onDrop={handleImageUpload}
|
||||||
|
>
|
||||||
|
{({getRootProps, getInputProps, isDragActive}) => (
|
||||||
|
<Paper
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<CloudUploadIcon sx={{ fontSize: 48, color: 'grey.400', mb: 1 }} />
|
||||||
|
<Typography>
|
||||||
|
{isDragActive
|
||||||
|
? 'ここにファイルをドロップ'
|
||||||
|
: '画像をドラッグ&ドロップまたはクリックして選択'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="textSecondary">
|
||||||
|
JPEG, PNG, GIF, WebP (最大10MB)
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{previewUrl && (
|
||||||
|
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||||
|
<img src={previewUrl} alt="プレビュー" style={{ maxWidth: 300, maxHeight: 200 }} />
|
||||||
|
<Button onClick={handleRemoveImage} color="error" startIcon={<DeleteIcon />}>
|
||||||
|
画像を削除
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 貸出管理画面
|
||||||
|
|
||||||
|
#### 貸出一覧画面 (`/loans`)
|
||||||
|
|
||||||
|
**機能要件**
|
||||||
|
- 貸出履歴の一覧表示
|
||||||
|
- フィルタリング(物品ID、学籍番号、アクティブ貸出のみ)
|
||||||
|
- ソート機能
|
||||||
|
- 返却処理(一括・個別)
|
||||||
|
- 貸出状況の可視化
|
||||||
|
|
||||||
|
**表示カラム**
|
||||||
|
- 貸出ID
|
||||||
|
- 物品名(ラベルID)
|
||||||
|
- 学籍番号
|
||||||
|
- 氏名
|
||||||
|
- 所属
|
||||||
|
- 貸出日
|
||||||
|
- 返却予定日
|
||||||
|
- 返却日
|
||||||
|
- 状態(貸出中/返却済み)
|
||||||
|
- アクション
|
||||||
|
|
||||||
|
**フィルター UI**
|
||||||
|
```tsx
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={3}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="学籍番号で検索"
|
||||||
|
value={studentNumberFilter}
|
||||||
|
onChange={handleStudentNumberChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={3}>
|
||||||
|
<Autocomplete
|
||||||
|
options={items}
|
||||||
|
getOptionLabel={(option) => `${option.name} (${option.label_id})`}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} placeholder="物品で検索" />
|
||||||
|
)}
|
||||||
|
onChange={handleItemFilterChange}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={2}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>状態</InputLabel>
|
||||||
|
<Select value={statusFilter} onChange={handleStatusFilterChange}>
|
||||||
|
<MenuItem value="">すべて</MenuItem>
|
||||||
|
<MenuItem value="active">貸出中</MenuItem>
|
||||||
|
<MenuItem value="returned">返却済み</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={2}>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={handleNewLoan}>
|
||||||
|
新規貸出
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新規貸出画面 (`/loans/new`)
|
||||||
|
|
||||||
|
**機能要件**
|
||||||
|
- 物品選択(利用可能な物品のみ)
|
||||||
|
- 学生情報入力
|
||||||
|
- バリデーション
|
||||||
|
- 貸出可能性チェック
|
||||||
|
|
||||||
|
**フォームフィールド**
|
||||||
|
```typescript
|
||||||
|
interface LoanFormData {
|
||||||
|
item_id: number; // 必須
|
||||||
|
student_number: string; // 必須
|
||||||
|
student_name: string; // 必須
|
||||||
|
organization?: string;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 返却処理画面 (`/loans/:id/return`)
|
||||||
|
|
||||||
|
**機能要件**
|
||||||
|
- 返却日時設定(デフォルト:現在時刻)
|
||||||
|
- 返却時の備考入力
|
||||||
|
- 物品状態確認
|
||||||
|
|
||||||
|
### 4. 共通コンポーネント
|
||||||
|
|
||||||
|
#### レスポンシブデザイン
|
||||||
|
- モバイル対応(768px以下)
|
||||||
|
- タブレット対応(768px-1024px)
|
||||||
|
- デスクトップ対応(1024px以上)
|
||||||
|
|
||||||
|
#### 通知システム
|
||||||
|
```tsx
|
||||||
|
// Snackbar通知
|
||||||
|
const showNotification = (message: string, severity: 'success' | 'error' | 'warning' | 'info') => {
|
||||||
|
// MUI Snackbar + Alert実装
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用例
|
||||||
|
showNotification('物品が正常に登録されました', 'success');
|
||||||
|
showNotification('エラーが発生しました', 'error');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ローディング状態
|
||||||
|
```tsx
|
||||||
|
// スケルトンローディング
|
||||||
|
<Skeleton variant="rectangular" height={400} />
|
||||||
|
|
||||||
|
// プログレスインジケーター
|
||||||
|
<CircularProgress />
|
||||||
|
|
||||||
|
// データテーブルローディング
|
||||||
|
<DataGrid loading={isLoading} ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### エラーハンドリング
|
||||||
|
```tsx
|
||||||
|
// エラーバウンダリ
|
||||||
|
<ErrorBoundary fallback={<ErrorFallback />}>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
// APIエラー処理
|
||||||
|
const handleApiError = (error: any) => {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
showNotification('データが見つかりません', 'error');
|
||||||
|
} else if (error.response?.status >= 500) {
|
||||||
|
showNotification('サーバーエラーが発生しました', 'error');
|
||||||
|
} else {
|
||||||
|
showNotification('予期しないエラーが発生しました', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## プロジェクト構成
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # 共通コンポーネント
|
||||||
|
│ ├── ui/ # 基本UIコンポーネント
|
||||||
|
│ ├── forms/ # フォームコンポーネント
|
||||||
|
│ ├── tables/ # テーブルコンポーネント
|
||||||
|
│ └── layout/ # レイアウトコンポーネント
|
||||||
|
├── pages/ # ページコンポーネント
|
||||||
|
│ ├── items/ # 物品管理ページ
|
||||||
|
│ ├── loans/ # 貸出管理ページ
|
||||||
|
│ └── dashboard/ # ダッシュボード
|
||||||
|
├── hooks/ # カスタムフック
|
||||||
|
├── services/ # API呼び出し
|
||||||
|
├── types/ # TypeScript型定義
|
||||||
|
├── utils/ # ユーティリティ関数
|
||||||
|
├── stores/ # 状態管理
|
||||||
|
└── constants/ # 定数定義
|
||||||
|
```
|
||||||
|
|
||||||
|
## 実装優先度
|
||||||
|
|
||||||
|
### Phase 1: 基本機能
|
||||||
|
1. プロジェクトセットアップ(Vite + Preact + TypeScript + MUI)
|
||||||
|
2. ルーティング設定
|
||||||
|
3. レイアウトコンポーネント
|
||||||
|
4. 物品一覧画面
|
||||||
|
5. 物品登録・編集画面
|
||||||
|
|
||||||
|
### Phase 2: 拡張機能
|
||||||
|
1. 貸出管理機能
|
||||||
|
2. 画像アップロード機能
|
||||||
|
3. 検索・フィルタリング機能強化
|
||||||
|
4. レスポンシブデザイン
|
||||||
|
|
||||||
|
### Phase 3: 改善・最適化
|
||||||
|
1. パフォーマンス最適化
|
||||||
|
2. エラーハンドリング強化
|
||||||
|
3. ユーザビリティ改善
|
||||||
|
4. テスト実装
|
||||||
|
|
||||||
|
## 開発環境設定
|
||||||
|
|
||||||
|
### 必要なパッケージ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.19.0",
|
||||||
|
"@preact/signals": "^1.2.0",
|
||||||
|
"preact-router": "^4.1.0",
|
||||||
|
"@mui/material": "^5.15.0",
|
||||||
|
"@mui/icons-material": "^5.15.0",
|
||||||
|
"@mui/x-data-grid": "^6.18.0",
|
||||||
|
"@emotion/react": "^11.11.0",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"react-hook-form": "^7.48.0",
|
||||||
|
"yup": "^1.4.0",
|
||||||
|
"@hookform/resolvers": "^3.3.0",
|
||||||
|
"react-dropzone": "^14.2.0",
|
||||||
|
"axios": "^1.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.8.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"typescript": "^5.2.0",
|
||||||
|
"@types/node": "^20.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vite設定 (vite.config.ts)
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8081',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'esnext',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript設定 (tsconfig.json)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API クライアント実装例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/services/api.ts
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// リクエストインターセプター
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
// 将来的にJWTトークンを追加
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// レスポンスインターセプター
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
// エラーハンドリング
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
// src/services/items.ts
|
||||||
|
export const itemsApi = {
|
||||||
|
getItems: (params: GetItemsParams) =>
|
||||||
|
api.get<ItemsListResponse>('/items', { params }),
|
||||||
|
|
||||||
|
getItem: (id: number) =>
|
||||||
|
api.get<Item>(`/items/${id}`),
|
||||||
|
|
||||||
|
createItem: (data: CreateItemRequest) =>
|
||||||
|
api.post<Item>('/items', data),
|
||||||
|
|
||||||
|
updateItem: (id: number, data: UpdateItemRequest) =>
|
||||||
|
api.put<Item>(`/items/${id}`, data),
|
||||||
|
|
||||||
|
deleteItem: (id: number) =>
|
||||||
|
api.delete(`/items/${id}`),
|
||||||
|
|
||||||
|
disposeItem: (id: number) =>
|
||||||
|
api.post<Item>(`/items/${id}/dispose`),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
この仕様書に基づいて、モダンで使いやすいExcel風UIを持つHyperDashiフロントエンドを実装してください。
|
||||||
161
ITEM_INTERFACE_COMPLETE.md
Normal file
161
ITEM_INTERFACE_COMPLETE.md
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Item Interface 完全対応UI - 実装完了
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
`interface Item` に定義されている全てのフィールドに対応した、完全な確認/編集UIを実装しました。
|
||||||
|
|
||||||
|
## 実装したフィールド
|
||||||
|
|
||||||
|
### ✅ 基本情報
|
||||||
|
- **id**: システム管理用(自動生成)
|
||||||
|
- **name**: 備品名(必須)
|
||||||
|
- **label_id**: ラベルID(必須)
|
||||||
|
- **model_number**: 型番(任意)
|
||||||
|
- **remarks**: 備考(任意、マルチライン)
|
||||||
|
|
||||||
|
### ✅ 購入・管理情報
|
||||||
|
- **purchase_year**: 購入年(数値入力)
|
||||||
|
- **purchase_amount**: 購入金額(数値入力、円表示)
|
||||||
|
- **durability_years**: 耐用年数(数値入力、年表示)
|
||||||
|
- **is_depreciation_target**: 減価償却対象(スイッチ)
|
||||||
|
|
||||||
|
### ✅ 配列フィールド(新規実装)
|
||||||
|
- **connection_names**: 接続名称(動的配列入力)
|
||||||
|
- **cable_color_pattern**: ケーブル色パターン(動的配列入力)
|
||||||
|
- **storage_locations**: 保管場所(動的配列入力)
|
||||||
|
|
||||||
|
### ✅ ステータス管理
|
||||||
|
- **is_on_loan**: 貸出中フラグ(スイッチ)
|
||||||
|
- **is_disposed**: 廃棄済みフラグ(スイッチ)
|
||||||
|
- **qr_code_type**: QRコードタイプ(セレクト: qr/barcode/none)
|
||||||
|
|
||||||
|
### ✅ 画像管理(新規実装)
|
||||||
|
- **image_url**: 画像URL(ドラッグ&ドロップアップロード)
|
||||||
|
|
||||||
|
### ✅ システム情報
|
||||||
|
- **created_at**: 作成日時(表示のみ)
|
||||||
|
- **updated_at**: 更新日時(表示のみ)
|
||||||
|
|
||||||
|
## 新規作成したコンポーネント
|
||||||
|
|
||||||
|
### 1. ArrayInput (`/src/components/ui/ArrayInput.tsx`)
|
||||||
|
**機能:**
|
||||||
|
- 動的な配列フィールド入力
|
||||||
|
- 項目の追加・削除
|
||||||
|
- 重複チェック
|
||||||
|
- 最大項目数制限
|
||||||
|
- Enterキーでの項目追加
|
||||||
|
|
||||||
|
**使用例:**
|
||||||
|
```tsx
|
||||||
|
<ArrayInput
|
||||||
|
label="接続名称"
|
||||||
|
placeholder="例: HDMI、USB-C、電源"
|
||||||
|
values={formValues.connection_names || []}
|
||||||
|
onChange={(values) => setValue('connection_names', values)}
|
||||||
|
maxItems={20}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ImageUpload (`/src/components/ui/ImageUpload.tsx`)
|
||||||
|
**機能:**
|
||||||
|
- ドラッグ&ドロップでの画像アップロード
|
||||||
|
- プレビュー表示
|
||||||
|
- 進捗表示
|
||||||
|
- ファイル形式・サイズ制限
|
||||||
|
- 画像削除機能
|
||||||
|
|
||||||
|
**対応形式:** JPEG, PNG, GIF, WebP (最大10MB)
|
||||||
|
|
||||||
|
## 更新されたページ
|
||||||
|
|
||||||
|
### 1. ItemForm (`/src/pages/items/ItemForm.tsx`)
|
||||||
|
**追加された機能:**
|
||||||
|
- 3つのCardセクションに分割
|
||||||
|
- 基本情報
|
||||||
|
- 接続・配線情報(配列フィールド)
|
||||||
|
- 画像アップロード
|
||||||
|
- 全フィールドの完全対応
|
||||||
|
- 既存データの正確な反映
|
||||||
|
|
||||||
|
### 2. ItemDetail (`/src/pages/items/ItemDetail.tsx`)
|
||||||
|
**追加された機能:**
|
||||||
|
- 全フィールドの表示
|
||||||
|
- 配列フィールドのChip表示
|
||||||
|
- セクション分割による見やすいレイアウト
|
||||||
|
- QRコードタイプの適切な表示
|
||||||
|
|
||||||
|
### 3. ItemsList (`/src/pages/items/ItemsList.tsx`)
|
||||||
|
**追加された機能:**
|
||||||
|
- 購入年フィールドの追加
|
||||||
|
- QRコードタイプのChip表示
|
||||||
|
- 保管場所の省略表示(2項目まで+...)
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 新規備品登録
|
||||||
|
1. 「備品追加」または「新規登録」ボタンをクリック
|
||||||
|
2. 基本情報セクションで必須フィールドを入力
|
||||||
|
3. 接続・配線情報セクションで配列フィールドを入力(任意)
|
||||||
|
4. 画像セクションで画像をアップロード(任意)
|
||||||
|
5. 「登録」ボタンで保存
|
||||||
|
|
||||||
|
### 備品編集
|
||||||
|
1. 備品一覧から編集ボタンをクリック
|
||||||
|
2. 既存データが全フィールドに自動反映
|
||||||
|
3. 必要な修正を実施
|
||||||
|
4. 「更新」ボタンで保存
|
||||||
|
|
||||||
|
### 備品詳細確認
|
||||||
|
1. 備品一覧から詳細ボタンをクリック
|
||||||
|
2. 全フィールドが整理されて表示
|
||||||
|
3. 配列フィールドはChipで視覚的に表示
|
||||||
|
4. 画像がある場合は表示
|
||||||
|
|
||||||
|
## 配列フィールドの操作
|
||||||
|
|
||||||
|
### 項目追加
|
||||||
|
- 入力欄にテキストを入力
|
||||||
|
- Enterキーまたは「+」ボタンで追加
|
||||||
|
- 重複項目は追加されない
|
||||||
|
|
||||||
|
### 項目削除
|
||||||
|
- 各ChipのXボタンをクリック
|
||||||
|
|
||||||
|
### 制限
|
||||||
|
- **接続名称**: 最大20項目
|
||||||
|
- **ケーブル色パターン**: 最大10項目
|
||||||
|
- **保管場所**: 最大5項目
|
||||||
|
|
||||||
|
## 画像アップロード
|
||||||
|
|
||||||
|
### 対応操作
|
||||||
|
- ドラッグ&ドロップ
|
||||||
|
- クリックしてファイル選択
|
||||||
|
- 既存画像の削除
|
||||||
|
|
||||||
|
### 制限事項
|
||||||
|
- 最大ファイルサイズ: 10MB
|
||||||
|
- 対応形式: JPEG, PNG, GIF, WebP
|
||||||
|
- 1つの備品につき1つの画像
|
||||||
|
|
||||||
|
## 技術的特徴
|
||||||
|
|
||||||
|
### フォーム管理
|
||||||
|
- React Hook Formによる効率的な状態管理
|
||||||
|
- 個別`setValue`による確実なフィールド更新
|
||||||
|
- リアルタイムバリデーション
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
- HeroUIコンポーネントによる統一感
|
||||||
|
- レスポンシブデザイン
|
||||||
|
- 直感的な操作性
|
||||||
|
|
||||||
|
### データ型対応
|
||||||
|
- 文字列フィールド
|
||||||
|
- 数値フィールド(適切な型変換)
|
||||||
|
- 真偽値フィールド(Switch UI)
|
||||||
|
- 配列フィールド(動的追加・削除)
|
||||||
|
- 選択式フィールド(Select UI)
|
||||||
|
|
||||||
|
Itemインターフェースの全フィールドに完全対応した、使いやすく直感的なUIが完成しました!
|
||||||
134
README.md
Normal file
134
README.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# HyperDashi Client
|
||||||
|
|
||||||
|
HyperDashi(ハイパーダッシ)は、教育機関向けの備品管理システムです。このプロジェクトは React + Vite + HeroUI を使用したフロントエンドです。
|
||||||
|
|
||||||
|
## 機能
|
||||||
|
|
||||||
|
- 📦 **備品管理**: 備品の登録・編集・削除・検索
|
||||||
|
- 📋 **貸出管理**: 備品の貸出・返却管理
|
||||||
|
- 🖼️ **画像管理**: 備品画像のアップロード・表示
|
||||||
|
- 📊 **ダッシュボード**: 統計情報の表示
|
||||||
|
- 🔍 **検索機能**: 備品名・ラベルID・型番での検索
|
||||||
|
- 📱 **レスポンシブデザイン**: モバイル・タブレット・デスクトップ対応
|
||||||
|
|
||||||
|
## 技術スタック
|
||||||
|
|
||||||
|
- **Framework**: React 18
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
- **UI Library**: HeroUI (NextUI v2)
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **State Management**: TanStack Query
|
||||||
|
- **HTTP Client**: Axios
|
||||||
|
- **Form Handling**: React Hook Form
|
||||||
|
- **Routing**: React Router
|
||||||
|
- **Icons**: Lucide React
|
||||||
|
- **Date Handling**: date-fns
|
||||||
|
|
||||||
|
## 開発環境のセットアップ
|
||||||
|
|
||||||
|
### 必要なもの
|
||||||
|
|
||||||
|
- Node.js 18以上
|
||||||
|
- npm または yarn
|
||||||
|
|
||||||
|
### インストール
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 依存関係のインストール
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 開発サーバーの起動
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# ビルド
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# プレビュー
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### 環境変数
|
||||||
|
|
||||||
|
プロジェクトルートに `.env` ファイルがあります。必要に応じて設定を変更してください:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# APIサーバーのURL(デフォルト: http://127.0.0.1:8080/api/v1)
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:8080/api/v1
|
||||||
|
|
||||||
|
# 開発モード(APIリクエストのログ出力)
|
||||||
|
VITE_DEV_MODE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: `.env.example` ファイルをコピーして `.env` ファイルを作成し、環境に合わせて設定を変更してください。
|
||||||
|
|
||||||
|
## プロジェクト構成
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # 再利用可能なコンポーネント
|
||||||
|
│ ├── ui/ # 基本UIコンポーネント
|
||||||
|
│ ├── layout/ # レイアウトコンポーネント
|
||||||
|
│ ├── forms/ # フォームコンポーネント
|
||||||
|
│ └── tables/ # テーブルコンポーネント
|
||||||
|
├── pages/ # ページコンポーネント
|
||||||
|
│ ├── dashboard/ # ダッシュボード
|
||||||
|
│ ├── items/ # 備品管理
|
||||||
|
│ └── loans/ # 貸出管理
|
||||||
|
├── hooks/ # カスタムフック
|
||||||
|
├── services/ # API呼び出し
|
||||||
|
├── types/ # 型定義
|
||||||
|
├── utils/ # ユーティリティ関数
|
||||||
|
└── styles/ # スタイル
|
||||||
|
```
|
||||||
|
|
||||||
|
## API仕様
|
||||||
|
|
||||||
|
バックエンドAPIは以下のベースURLで動作します:
|
||||||
|
- 開発環境: `http://127.0.0.1:8080/api/v1`(環境変数 `VITE_API_BASE_URL` で変更可能)
|
||||||
|
|
||||||
|
### 主要エンドポイント
|
||||||
|
|
||||||
|
- `GET /items` - 備品一覧取得
|
||||||
|
- `POST /items` - 備品作成
|
||||||
|
- `GET /items/:id` - 備品詳細取得
|
||||||
|
- `PUT /items/:id` - 備品更新
|
||||||
|
- `DELETE /items/:id` - 備品削除
|
||||||
|
- `GET /loans` - 貸出一覧取得
|
||||||
|
- `POST /loans` - 貸出作成
|
||||||
|
- `PUT /loans/:id/return` - 貸出返却
|
||||||
|
|
||||||
|
## 開発ガイド
|
||||||
|
|
||||||
|
### コーディング規約
|
||||||
|
|
||||||
|
- TypeScriptを使用
|
||||||
|
- ESLintとPrettierによるコードフォーマット
|
||||||
|
- コンポーネント名はPascalCase
|
||||||
|
- ファイル名はkebab-case(コンポーネントファイルは除く)
|
||||||
|
|
||||||
|
### コミット規約
|
||||||
|
|
||||||
|
- feat: 新機能
|
||||||
|
- fix: バグ修正
|
||||||
|
- docs: ドキュメント更新
|
||||||
|
- style: スタイル変更
|
||||||
|
- refactor: リファクタリング
|
||||||
|
- test: テスト追加・修正
|
||||||
|
- chore: その他の変更
|
||||||
|
|
||||||
|
## デプロイ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本番ビルド
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 生成されたdistディレクトリをWebサーバーにデプロイ
|
||||||
|
```
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 貢献
|
||||||
|
|
||||||
|
プルリクエストやイシューの投稿を歓迎します。
|
||||||
58
SETUP.md
Normal file
58
SETUP.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# セットアップガイド
|
||||||
|
|
||||||
|
## バックエンド接続確認
|
||||||
|
|
||||||
|
✅ **接続状況**: バックエンドAPI(ポート8080)との接続が確認できました
|
||||||
|
|
||||||
|
### 確認したエンドポイント
|
||||||
|
|
||||||
|
- `GET /api/v1/items` - 正常にレスポンス(3件のテストデータを確認)
|
||||||
|
- データ例:
|
||||||
|
- "Test Item" (TEST001)
|
||||||
|
- "ぽえ" (0X23)
|
||||||
|
- "マヌケ" (0X13)
|
||||||
|
|
||||||
|
### 環境設定
|
||||||
|
|
||||||
|
現在の設定:
|
||||||
|
- **API Base URL**: `http://127.0.0.1:8080/api/v1`
|
||||||
|
- **開発モード**: 有効(APIリクエストログ出力)
|
||||||
|
|
||||||
|
### 実装した機能
|
||||||
|
|
||||||
|
1. **環境変数サポート**
|
||||||
|
- `.env` ファイルでAPIエンドポイント設定可能
|
||||||
|
- `VITE_API_BASE_URL` で接続先変更可能
|
||||||
|
|
||||||
|
2. **接続ステータス表示**
|
||||||
|
- ナビバーに接続状況のChip表示
|
||||||
|
- 30秒間隔で自動接続チェック
|
||||||
|
- クリックで手動接続確認
|
||||||
|
|
||||||
|
3. **エラーハンドリング強化**
|
||||||
|
- ネットワークエラーの日本語メッセージ
|
||||||
|
- 開発モードでのリクエスト/レスポンスログ
|
||||||
|
- APIエラーの詳細表示
|
||||||
|
|
||||||
|
4. **実データ連携**
|
||||||
|
- 備品一覧ページで実際のAPIデータ取得
|
||||||
|
- ページネーション対応
|
||||||
|
- 検索機能準備完了
|
||||||
|
|
||||||
|
## 次のステップ
|
||||||
|
|
||||||
|
1. **開発サーバー起動**: `npm run dev`
|
||||||
|
2. **ブラウザで確認**: http://localhost:5173
|
||||||
|
3. **API接続状況**: ナビバーの接続ステータスで確認
|
||||||
|
4. **備品一覧**: 実際のバックエンドデータが表示される
|
||||||
|
|
||||||
|
## トラブルシューティング
|
||||||
|
|
||||||
|
### バックエンドが見つからない場合
|
||||||
|
- `.env` ファイルの `VITE_API_BASE_URL` を確認
|
||||||
|
- バックエンドサーバーが起動しているか確認
|
||||||
|
- ポート番号が正しいか確認(デフォルト: 8080)
|
||||||
|
|
||||||
|
### CORS エラーの場合
|
||||||
|
- バックエンドのCORS設定を確認
|
||||||
|
- 開発環境では通常自動設定される
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1751271578,
|
||||||
|
"narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
34
flake.nix
Normal file
34
flake.nix
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
description = "HyperDashi Frontend Development Environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
nodejs_20
|
||||||
|
bun
|
||||||
|
nodePackages.typescript
|
||||||
|
nodePackages.typescript-language-server
|
||||||
|
nodePackages.vscode-langservers-extracted
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "🚀 HyperDashi Frontend Development Environment"
|
||||||
|
echo "Node: $(node --version)"
|
||||||
|
echo "Bun: $(bun --version)"
|
||||||
|
echo "TypeScript: $(tsc --version)"
|
||||||
|
echo ""
|
||||||
|
echo "Ready to develop with Preact + TypeScript + MUI!"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HyperDashi - Equipment Management System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11321
package-lock.json
generated
Normal file
11321
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
package.json
Normal file
44
package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"name": "hyperdashi-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroui/react": "^2.4.24",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"@tanstack/react-table": "^8.20.7",
|
||||||
|
"@zxing/library": "^0.21.3",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-dropzone": "^14.3.5",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
||||||
|
"@typescript-eslint/parser": "^8.17.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
postcss.config.cjs
Normal file
8
postcss.config.cjs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {
|
||||||
|
base: './src/index.css',
|
||||||
|
config: './tailwind.config.cjs'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
24
src/App.tsx
Normal file
24
src/App.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { AppRoutes } from './routes'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
106
src/components/layout/Layout.tsx
Normal file
106
src/components/layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Navbar,
|
||||||
|
NavbarBrand,
|
||||||
|
NavbarContent,
|
||||||
|
NavbarItem,
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownTrigger,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
Avatar
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ConnectionStatus } from '@/components/ui/ConnectionStatus'
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
const location = useLocation()
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
return location.pathname.startsWith(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar
|
||||||
|
maxWidth="full"
|
||||||
|
isBordered
|
||||||
|
isMenuOpen={isMenuOpen}
|
||||||
|
onMenuOpenChange={setIsMenuOpen}
|
||||||
|
>
|
||||||
|
<NavbarContent>
|
||||||
|
<NavbarBrand>
|
||||||
|
<Link to="/" className="font-bold text-xl text-primary">
|
||||||
|
HyperDashi
|
||||||
|
</Link>
|
||||||
|
</NavbarBrand>
|
||||||
|
</NavbarContent>
|
||||||
|
|
||||||
|
<NavbarContent className="hidden sm:flex gap-4" justify="center">
|
||||||
|
<NavbarItem isActive={location.pathname === '/'}>
|
||||||
|
<Link to="/" className={location.pathname === '/' ? 'text-primary' : ''}>
|
||||||
|
ダッシュボード
|
||||||
|
</Link>
|
||||||
|
</NavbarItem>
|
||||||
|
<NavbarItem isActive={isActive('/items')}>
|
||||||
|
<Link to="/items" className={isActive('/items') ? 'text-primary' : ''}>
|
||||||
|
備品管理
|
||||||
|
</Link>
|
||||||
|
</NavbarItem>
|
||||||
|
<NavbarItem isActive={isActive('/loans')}>
|
||||||
|
<Link to="/loans" className={isActive('/loans') ? 'text-primary' : ''}>
|
||||||
|
貸出管理
|
||||||
|
</Link>
|
||||||
|
</NavbarItem>
|
||||||
|
<NavbarItem isActive={isActive('/cable-colors')}>
|
||||||
|
<Link to="/cable-colors" className={isActive('/cable-colors') ? 'text-primary' : ''}>
|
||||||
|
ケーブル色管理
|
||||||
|
</Link>
|
||||||
|
</NavbarItem>
|
||||||
|
</NavbarContent>
|
||||||
|
|
||||||
|
<NavbarContent justify="end">
|
||||||
|
<NavbarItem>
|
||||||
|
<ConnectionStatus />
|
||||||
|
</NavbarItem>
|
||||||
|
<NavbarItem>
|
||||||
|
<Button as={Link} color="primary" to="/items/new" variant="flat">
|
||||||
|
備品追加
|
||||||
|
</Button>
|
||||||
|
</NavbarItem>
|
||||||
|
<NavbarItem>
|
||||||
|
<Dropdown placement="bottom-end">
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Avatar
|
||||||
|
isBordered
|
||||||
|
as="button"
|
||||||
|
className="transition-transform"
|
||||||
|
color="primary"
|
||||||
|
name="管理者"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu aria-label="User Actions" variant="flat">
|
||||||
|
<DropdownItem key="profile" className="h-14 gap-2">
|
||||||
|
<p className="font-semibold">ログイン中</p>
|
||||||
|
<p className="text-sm">admin@example.com</p>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem key="settings">設定</DropdownItem>
|
||||||
|
<DropdownItem key="help_and_feedback">ヘルプ&フィードバック</DropdownItem>
|
||||||
|
<DropdownItem key="logout" color="danger">
|
||||||
|
ログアウト
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</NavbarItem>
|
||||||
|
</NavbarContent>
|
||||||
|
</Navbar>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/components/ui/ArrayInput.tsx
Normal file
113
src/components/ui/ArrayInput.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Chip, Autocomplete, AutocompleteItem } from '@heroui/react'
|
||||||
|
import { Plus, X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ArrayInputProps {
|
||||||
|
label: string
|
||||||
|
placeholder?: string
|
||||||
|
values: string[]
|
||||||
|
onChange: (values: string[]) => void
|
||||||
|
maxItems?: number
|
||||||
|
suggestions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArrayInput({
|
||||||
|
label,
|
||||||
|
placeholder = '',
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
maxItems = 10,
|
||||||
|
suggestions = []
|
||||||
|
}: ArrayInputProps) {
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
if (inputValue.trim() && !values.includes(inputValue.trim()) && values.length < maxItems) {
|
||||||
|
onChange([...values, inputValue.trim()])
|
||||||
|
setInputValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
const newValues = values.filter((_, i) => i !== index)
|
||||||
|
onChange(newValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectionChange = (key: React.Key | null) => {
|
||||||
|
if (key && typeof key === 'string') {
|
||||||
|
setInputValue(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter suggestions that aren't already in values
|
||||||
|
const availableSuggestions = suggestions.filter(suggestion =>
|
||||||
|
!values.includes(suggestion)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">{label}</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Autocomplete
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={setInputValue}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
className="flex-1"
|
||||||
|
allowsCustomValue
|
||||||
|
menuTrigger="input"
|
||||||
|
>
|
||||||
|
{availableSuggestions.map((suggestion) => (
|
||||||
|
<AutocompleteItem key={suggestion}>
|
||||||
|
{suggestion}
|
||||||
|
</AutocompleteItem>
|
||||||
|
))}
|
||||||
|
</Autocomplete>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={addItem}
|
||||||
|
isDisabled={!inputValue.trim() || values.includes(inputValue.trim()) || values.length >= maxItems}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
variant="flat"
|
||||||
|
color="primary"
|
||||||
|
endContent={
|
||||||
|
<button
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="ml-1 hover:bg-red-100 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{values.length}/{maxItems} 項目 • Enterで追加
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
src/components/ui/CableColorInput.tsx
Normal file
139
src/components/ui/CableColorInput.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Chip, Select, SelectItem } from '@heroui/react'
|
||||||
|
import { Plus, X } from 'lucide-react'
|
||||||
|
import { useCableColors } from '@/hooks'
|
||||||
|
|
||||||
|
interface CableColorInputProps {
|
||||||
|
label: string
|
||||||
|
values: string[]
|
||||||
|
onChange: (values: string[]) => void
|
||||||
|
maxItems?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CableColorInput({
|
||||||
|
label,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
maxItems = 10
|
||||||
|
}: CableColorInputProps) {
|
||||||
|
const [selectedColorId, setSelectedColorId] = useState<string>('')
|
||||||
|
const { data: cableColorsData } = useCableColors()
|
||||||
|
|
||||||
|
const cableColors = cableColorsData?.data || []
|
||||||
|
|
||||||
|
const addColor = () => {
|
||||||
|
if (selectedColorId && values.length < maxItems) {
|
||||||
|
const selectedColor = cableColors.find(color => color.id.toString() === selectedColorId)
|
||||||
|
if (selectedColor && !values.includes(selectedColor.name)) {
|
||||||
|
onChange([...values, selectedColor.name])
|
||||||
|
setSelectedColorId('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeColor = (index: number) => {
|
||||||
|
const newValues = values.filter((_, i) => i !== index)
|
||||||
|
onChange(newValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get color hex code by name
|
||||||
|
const getColorHex = (colorName: string) => {
|
||||||
|
const color = cableColors.find(c => c.name === colorName)
|
||||||
|
return color?.hex_code || '#000000'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available colors (not already selected)
|
||||||
|
const availableColors = cableColors.filter(color =>
|
||||||
|
!values.includes(color.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium">{label}</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
placeholder="色を選択してください"
|
||||||
|
selectedKeys={selectedColorId ? [selectedColorId] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0] as string
|
||||||
|
setSelectedColorId(key || '')
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
renderValue={(items) => {
|
||||||
|
const item = items[0]
|
||||||
|
if (!item) return null
|
||||||
|
const color = cableColors.find(c => c.id.toString() === item.key)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded border border-gray-300"
|
||||||
|
style={{ backgroundColor: color?.hex_code }}
|
||||||
|
/>
|
||||||
|
<span>{color?.name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{availableColors.map((color) => (
|
||||||
|
<SelectItem
|
||||||
|
key={color.id}
|
||||||
|
startContent={
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded border border-gray-300"
|
||||||
|
style={{ backgroundColor: color.hex_code }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{color.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={addColor}
|
||||||
|
isDisabled={!selectedColorId || values.length >= maxItems}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-gray-600">選択された色の順序(左:端子側 → 右:ケーブル側):</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{values.map((colorName, index) => (
|
||||||
|
<Chip
|
||||||
|
key={index}
|
||||||
|
variant="flat"
|
||||||
|
color="primary"
|
||||||
|
startContent={
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded border border-gray-300"
|
||||||
|
style={{ backgroundColor: getColorHex(colorName) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
endContent={
|
||||||
|
<button
|
||||||
|
onClick={() => removeColor(index)}
|
||||||
|
className="ml-1 hover:bg-red-100 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{index + 1}. {colorName}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{values.length}/{maxItems} 色選択済み
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/components/ui/CableVisualization.tsx
Normal file
109
src/components/ui/CableVisualization.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useCableColors } from '@/hooks'
|
||||||
|
|
||||||
|
interface CableVisualizationProps {
|
||||||
|
colorNames: string[]
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
showLabels?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CableVisualization({
|
||||||
|
colorNames,
|
||||||
|
size = 'md',
|
||||||
|
showLabels = false
|
||||||
|
}: CableVisualizationProps) {
|
||||||
|
const { data: cableColorsData } = useCableColors()
|
||||||
|
const cableColors = cableColorsData?.data || []
|
||||||
|
|
||||||
|
// Get color hex code by name
|
||||||
|
const getColorHex = (colorName: string) => {
|
||||||
|
const color = cableColors.find(c => c.name === colorName)
|
||||||
|
return color?.hex_code || '#000000'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!colorNames.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
ケーブル色パターンなし
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size configurations
|
||||||
|
const sizeConfig = {
|
||||||
|
sm: {
|
||||||
|
height: 'h-2',
|
||||||
|
width: 'w-3',
|
||||||
|
gap: 'gap-0.5',
|
||||||
|
text: 'text-xs',
|
||||||
|
connector: 'w-2 h-0.5'
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
height: 'h-3',
|
||||||
|
width: 'w-4',
|
||||||
|
gap: 'gap-1',
|
||||||
|
text: 'text-xs',
|
||||||
|
connector: 'w-3 h-0.5'
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
height: 'h-4',
|
||||||
|
width: 'w-6',
|
||||||
|
gap: 'gap-1',
|
||||||
|
text: 'text-sm',
|
||||||
|
connector: 'w-4 h-0.5'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = sizeConfig[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{/* Cable visualization */}
|
||||||
|
<div className={`flex items-center ${config.gap}`}>
|
||||||
|
{/* Left connector (端子) */}
|
||||||
|
<div className={`${config.connector} bg-gray-400 rounded-sm`} />
|
||||||
|
|
||||||
|
{/* Cable segments */}
|
||||||
|
{colorNames.map((colorName, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`${config.height} ${config.width} border border-gray-300 shadow-sm`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: getColorHex(colorName),
|
||||||
|
borderRadius: index === 0 ? '2px 0 0 2px' :
|
||||||
|
index === colorNames.length - 1 ? '0 2px 2px 0' : '0'
|
||||||
|
}}
|
||||||
|
title={`${index + 1}. ${colorName}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Right connector (端子) */}
|
||||||
|
<div className={`${config.connector} bg-gray-400 rounded-sm`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels (optional) */}
|
||||||
|
{showLabels && (
|
||||||
|
<div className={`flex items-center ${config.gap} ${config.text} text-gray-600`}>
|
||||||
|
<span className="text-xs">端子側</span>
|
||||||
|
{colorNames.map((_, index) => (
|
||||||
|
<span key={index} className="text-center" style={{ minWidth: config.width.replace('w-', '') + 'rem' }}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-xs">ケーブル側</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color names (for reference) */}
|
||||||
|
{showLabels && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
端子側から順に: {colorNames.map((name, index) => (
|
||||||
|
<span key={index}>
|
||||||
|
{index + 1}.{name}
|
||||||
|
{index < colorNames.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/components/ui/ConnectionStatus.tsx
Normal file
54
src/components/ui/ConnectionStatus.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Chip } from '@heroui/react'
|
||||||
|
import { Wifi, WifiOff } from 'lucide-react'
|
||||||
|
import { healthService } from '@/services'
|
||||||
|
|
||||||
|
export function ConnectionStatus() {
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(null)
|
||||||
|
const [isChecking, setIsChecking] = useState(false)
|
||||||
|
|
||||||
|
const checkConnection = async () => {
|
||||||
|
setIsChecking(true)
|
||||||
|
try {
|
||||||
|
const connected = await healthService.ping()
|
||||||
|
setIsConnected(connected)
|
||||||
|
} catch (error) {
|
||||||
|
setIsConnected(false)
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkConnection()
|
||||||
|
// Check connection every 30 seconds
|
||||||
|
const interval = setInterval(checkConnection, 30000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (isConnected === null) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
color="default"
|
||||||
|
startContent={<Wifi size={14} />}
|
||||||
|
>
|
||||||
|
接続確認中...
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
color={isConnected ? 'success' : 'danger'}
|
||||||
|
startContent={isConnected ? <Wifi size={14} /> : <WifiOff size={14} />}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={checkConnection}
|
||||||
|
>
|
||||||
|
{isChecking ? '確認中...' : isConnected ? 'API接続済み' : 'API未接続'}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/components/ui/ExpandableItemRow.tsx
Normal file
140
src/components/ui/ExpandableItemRow.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { Card, CardBody, Chip } from '@heroui/react'
|
||||||
|
import { Item } from '@/types'
|
||||||
|
import { CableVisualization } from '@/components/ui/CableVisualization'
|
||||||
|
|
||||||
|
interface ExpandableItemRowProps {
|
||||||
|
item: Item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpandableItemRow({ item }: ExpandableItemRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-gray-50 border-t">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{/* 詳細情報セクション */}
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardBody className="p-3">
|
||||||
|
<h4 className="font-semibold text-sm mb-3">詳細情報</h4>
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">購入年</dt>
|
||||||
|
<dd className="font-medium">{item.purchase_year || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">購入金額</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{item.purchase_amount ? `¥${item.purchase_amount.toLocaleString()}` : '-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">耐用年数</dt>
|
||||||
|
<dd className="font-medium">{item.durability_years ? `${item.durability_years}年` : '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">減価償却対象</dt>
|
||||||
|
<dd>
|
||||||
|
<Chip
|
||||||
|
color={item.is_depreciation_target ? 'warning' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{item.is_depreciation_target ? '対象' : '対象外'}
|
||||||
|
</Chip>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 接続・配線情報セクション */}
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardBody className="p-3">
|
||||||
|
<h4 className="font-semibold text-sm mb-3">接続・配線情報</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{item.connection_names?.length ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600 text-xs mb-1">接続名称</dt>
|
||||||
|
<dd className="flex flex-wrap gap-1">
|
||||||
|
{item.connection_names.map((name, index) => (
|
||||||
|
<Chip key={index} size="sm" variant="flat" color="primary">
|
||||||
|
{name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.cable_color_pattern?.length ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600 text-xs mb-1">ケーブル色パターン</dt>
|
||||||
|
<dd>
|
||||||
|
<CableVisualization
|
||||||
|
colorNames={item.cable_color_pattern}
|
||||||
|
size="md"
|
||||||
|
showLabels={true}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 保管場所セクション */}
|
||||||
|
{item.storage_locations?.length ? (
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardBody className="p-3">
|
||||||
|
<h4 className="font-semibold text-sm mb-3">保管場所</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.storage_locations.map((location, index) => (
|
||||||
|
<Chip key={index} size="sm" variant="flat" color="success">
|
||||||
|
{location}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* システム情報・その他セクション */}
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardBody className="p-3">
|
||||||
|
<h4 className="font-semibold text-sm mb-3">システム情報</h4>
|
||||||
|
<dl className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">QRコードタイプ</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{item.qr_code_type === 'qr' ? 'QRコード' :
|
||||||
|
item.qr_code_type === 'barcode' ? 'バーコード' : 'なし'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">作成日</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{new Date(item.created_at).toLocaleDateString('ja-JP')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-600">更新日</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{new Date(item.updated_at).toLocaleDateString('ja-JP')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 備考セクション */}
|
||||||
|
{item.remarks && (
|
||||||
|
<Card className="shadow-sm mt-4">
|
||||||
|
<CardBody className="p-3">
|
||||||
|
<h4 className="font-semibold text-sm mb-2">備考</h4>
|
||||||
|
<p className="text-sm whitespace-pre-wrap bg-white p-2 rounded border">
|
||||||
|
{item.remarks}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/components/ui/ImageUpload.tsx
Normal file
168
src/components/ui/ImageUpload.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import { Button, Card, CardBody, Image, Progress } from '@heroui/react'
|
||||||
|
import { Upload, X, Image as ImageIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ImageUploadProps {
|
||||||
|
currentImageUrl?: string
|
||||||
|
onImageChange: (imageUrl: string | null) => void
|
||||||
|
maxSize?: number // MB
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUpload({
|
||||||
|
currentImageUrl,
|
||||||
|
onImageChange,
|
||||||
|
maxSize = 10
|
||||||
|
}: ImageUploadProps) {
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||||
|
const file = acceptedFiles[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setIsUploading(true)
|
||||||
|
setUploadProgress(0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate upload progress
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setUploadProgress(prev => {
|
||||||
|
if (prev >= 90) {
|
||||||
|
clearInterval(interval)
|
||||||
|
return 90
|
||||||
|
}
|
||||||
|
return prev + 10
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
// TODO: Implement actual image upload to API
|
||||||
|
// const formData = new FormData()
|
||||||
|
// formData.append('file', file)
|
||||||
|
// const response = await imagesService.upload(file)
|
||||||
|
|
||||||
|
// For now, create a local URL for preview
|
||||||
|
const localUrl = URL.createObjectURL(file)
|
||||||
|
|
||||||
|
// Complete progress
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploadProgress(100)
|
||||||
|
setTimeout(() => {
|
||||||
|
onImageChange(localUrl)
|
||||||
|
setIsUploading(false)
|
||||||
|
setUploadProgress(0)
|
||||||
|
}, 500)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setError('画像のアップロードに失敗しました')
|
||||||
|
setIsUploading(false)
|
||||||
|
setUploadProgress(0)
|
||||||
|
}
|
||||||
|
}, [onImageChange])
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
|
||||||
|
},
|
||||||
|
maxSize: maxSize * 1024 * 1024,
|
||||||
|
multiple: false,
|
||||||
|
onDropRejected: (rejectedFiles) => {
|
||||||
|
const rejection = rejectedFiles[0]?.errors[0]
|
||||||
|
if (rejection?.code === 'file-too-large') {
|
||||||
|
setError(`ファイルサイズが${maxSize}MBを超えています`)
|
||||||
|
} else if (rejection?.code === 'file-invalid-type') {
|
||||||
|
setError('対応していないファイル形式です')
|
||||||
|
} else {
|
||||||
|
setError('ファイルの処理に失敗しました')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
onImageChange(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-sm font-medium">備品画像</label>
|
||||||
|
|
||||||
|
{currentImageUrl ? (
|
||||||
|
<Card className="relative">
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Image
|
||||||
|
src={currentImageUrl}
|
||||||
|
alt="備品画像"
|
||||||
|
className="w-full h-48 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onPress={removeImage}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`border-2 border-dashed cursor-pointer transition-colors rounded-lg ${
|
||||||
|
isDragActive ? 'border-primary bg-primary-50' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="p-4 bg-gray-100 rounded-full">
|
||||||
|
{isUploading ? (
|
||||||
|
<Upload className="animate-bounce" size={24} />
|
||||||
|
) : (
|
||||||
|
<ImageIcon size={24} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<p className="text-sm">アップロード中...</p>
|
||||||
|
<Progress
|
||||||
|
value={uploadProgress}
|
||||||
|
color="primary"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-medium">
|
||||||
|
{isDragActive ? 'ここにドロップ' : '画像をアップロード'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
ドラッグ&ドロップ または クリックして選択
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
JPEG, PNG, GIF, WebP (最大{maxSize}MB)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-danger text-sm">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/hooks/index.ts
Normal file
3
src/hooks/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './useItems'
|
||||||
|
export * from './useLoans'
|
||||||
|
export * from './useCableColors'
|
||||||
55
src/hooks/useCableColors.ts
Normal file
55
src/hooks/useCableColors.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { cableColorsService } from '@/services/cableColors'
|
||||||
|
|
||||||
|
export function useCableColors(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['cableColors', params],
|
||||||
|
queryFn: () => cableColorsService.getAll(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCableColor(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['cableColors', id],
|
||||||
|
queryFn: () => cableColorsService.getById(id),
|
||||||
|
enabled: !!id && id > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCableColor() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: cableColorsService.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cableColors'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCableColor() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: { name?: string; hex_code?: string } }) =>
|
||||||
|
cableColorsService.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cableColors'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cableColors', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCableColor() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: cableColorsService.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cableColors'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
103
src/hooks/useItems.ts
Normal file
103
src/hooks/useItems.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { itemsService } from '@/services'
|
||||||
|
import { Item } from '@/types'
|
||||||
|
|
||||||
|
export function useItems(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
status?: 'available' | 'on_loan' | 'disposed'
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['items', params],
|
||||||
|
queryFn: () => itemsService.getAll(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useItem(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['items', id],
|
||||||
|
queryFn: () => itemsService.getById(id),
|
||||||
|
enabled: !!id && id > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: itemsService.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<Omit<Item, 'id' | 'created_at' | 'updated_at'>> }) =>
|
||||||
|
itemsService.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: itemsService.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadItemImage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, file }: { id: number; file: File }) =>
|
||||||
|
itemsService.uploadImage(id, file),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisposeItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: itemsService.dispose,
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUndisposeItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: itemsService.undispose,
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items', id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useItemSuggestions(field: 'connection_names' | 'cable_color_pattern' | 'storage_locations') {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['items', 'suggestions', field],
|
||||||
|
queryFn: () => itemsService.getSuggestions(field),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
69
src/hooks/useLoans.ts
Normal file
69
src/hooks/useLoans.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { loansService } from '@/services'
|
||||||
|
|
||||||
|
export function useLoans(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
status?: 'active' | 'returned'
|
||||||
|
item_id?: number
|
||||||
|
student_number?: string
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['loans', params],
|
||||||
|
queryFn: () => loansService.getAll(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoan(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['loans', id],
|
||||||
|
queryFn: () => loansService.getById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLoan() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: loansService.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['loans'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReturnItem() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: { remarks?: string } }) =>
|
||||||
|
loansService.returnItem(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['loans'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveLoan(itemId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['loans', 'active', itemId],
|
||||||
|
queryFn: () => loansService.getActiveByItemId(itemId),
|
||||||
|
enabled: !!itemId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoanHistory(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
item_id?: number
|
||||||
|
student_number?: string
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['loans', 'history', params],
|
||||||
|
queryFn: () => loansService.getHistory(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { HeroUIProvider } from '@heroui/react'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<HeroUIProvider>
|
||||||
|
<App />
|
||||||
|
</HeroUIProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
334
src/pages/cable-colors/CableColorsList.tsx
Normal file
334
src/pages/cable-colors/CableColorsList.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableColumn,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Spinner,
|
||||||
|
Pagination,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
useDisclosure,
|
||||||
|
Input,
|
||||||
|
Chip,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { Plus, Edit, Trash2, Palette } from 'lucide-react'
|
||||||
|
import { useCableColors, useCreateCableColor, useUpdateCableColor, useDeleteCableColor } from '@/hooks/useCableColors'
|
||||||
|
import { CableColor } from '@/types'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
|
||||||
|
type CableColorFormData = {
|
||||||
|
name: string
|
||||||
|
hex_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CableColorsList() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [editingColor, setEditingColor] = useState<CableColor | null>(null)
|
||||||
|
const [deletingColor, setDeletingColor] = useState<CableColor | null>(null)
|
||||||
|
|
||||||
|
const { isOpen: isFormOpen, onOpen: onFormOpen, onOpenChange: onFormOpenChange } = useDisclosure()
|
||||||
|
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onOpenChange: onDeleteOpenChange } = useDisclosure()
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useCableColors({ page, per_page: 20 })
|
||||||
|
const createMutation = useCreateCableColor()
|
||||||
|
const updateMutation = useUpdateCableColor()
|
||||||
|
const deleteMutation = useDeleteCableColor()
|
||||||
|
|
||||||
|
const colors = data?.data || []
|
||||||
|
const totalPages = data?.total_pages || 1
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm<CableColorFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
hex_code: '#000000'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchedHexCode = watch('hex_code')
|
||||||
|
|
||||||
|
const openCreateForm = () => {
|
||||||
|
setEditingColor(null)
|
||||||
|
reset({ name: '', hex_code: '#000000' })
|
||||||
|
onFormOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditForm = (color: CableColor) => {
|
||||||
|
setEditingColor(color)
|
||||||
|
setValue('name', color.name)
|
||||||
|
setValue('hex_code', color.hex_code)
|
||||||
|
onFormOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteConfirm = (color: CableColor) => {
|
||||||
|
setDeletingColor(color)
|
||||||
|
onDeleteOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (data: CableColorFormData) => {
|
||||||
|
try {
|
||||||
|
if (editingColor) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingColor.id, data })
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(data)
|
||||||
|
}
|
||||||
|
onFormOpenChange()
|
||||||
|
reset()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving cable color:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (deletingColor) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deletingColor.id)
|
||||||
|
onDeleteOpenChange()
|
||||||
|
setDeletingColor(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting cable color:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">ケーブル色管理</h1>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-danger">
|
||||||
|
エラーが発生しました: {(error as any)?.message || '不明なエラー'}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">ケーブル色管理</h1>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
startContent={<Plus size={20} />}
|
||||||
|
onPress={openCreateForm}
|
||||||
|
>
|
||||||
|
新規色登録
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<Table
|
||||||
|
aria-label="ケーブル色一覧"
|
||||||
|
removeWrapper
|
||||||
|
bottomContent={
|
||||||
|
totalPages > 1 && (
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>色見本</TableColumn>
|
||||||
|
<TableColumn>色名</TableColumn>
|
||||||
|
<TableColumn>カラーコード</TableColumn>
|
||||||
|
<TableColumn>作成日</TableColumn>
|
||||||
|
<TableColumn align="center">操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody
|
||||||
|
items={colors}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingContent={<Spinner label="読み込み中..." />}
|
||||||
|
emptyContent="登録されたケーブル色がありません"
|
||||||
|
>
|
||||||
|
{(color) => (
|
||||||
|
<TableRow key={color.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded border-2 border-gray-300"
|
||||||
|
style={{ backgroundColor: color.hex_code }}
|
||||||
|
/>
|
||||||
|
<Palette size={16} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium">{color.name}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip variant="flat" color="default" size="sm">
|
||||||
|
{color.hex_code}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{new Date(color.created_at).toLocaleDateString('ja-JP')}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 justify-center">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
title="編集"
|
||||||
|
onPress={() => openEditForm(color)}
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="danger"
|
||||||
|
title="削除"
|
||||||
|
onPress={() => openDeleteConfirm(color)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal isOpen={isFormOpen} onOpenChange={onFormOpenChange}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
{editingColor ? 'ケーブル色編集' : '新規ケーブル色登録'}
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
{...register('name', {
|
||||||
|
required: '色名は必須です',
|
||||||
|
maxLength: { value: 50, message: '色名は50文字以内で入力してください' }
|
||||||
|
})}
|
||||||
|
label="色名"
|
||||||
|
placeholder="例: 赤、青、緑"
|
||||||
|
errorMessage={errors.name?.message}
|
||||||
|
isInvalid={!!errors.name}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
{...register('hex_code', {
|
||||||
|
required: 'カラーコードは必須です',
|
||||||
|
pattern: {
|
||||||
|
value: /^#[0-9A-Fa-f]{6}$/,
|
||||||
|
message: 'カラーコードは#から始まる6桁の16進数で入力してください'
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
label="カラーコード"
|
||||||
|
placeholder="#000000"
|
||||||
|
errorMessage={errors.hex_code?.message}
|
||||||
|
isInvalid={!!errors.hex_code}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm text-gray-600">プレビュー:</span>
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded border-2 border-gray-300"
|
||||||
|
style={{ backgroundColor: watchedHexCode }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-mono">{watchedHexCode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
キャンセル
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingColor ? '更新' : '登録'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal isOpen={isDeleteOpen} onOpenChange={onDeleteOpenChange}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
ケーブル色削除確認
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>
|
||||||
|
「<strong>{deletingColor?.name}</strong>」を削除しますか?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded border-2 border-gray-300"
|
||||||
|
style={{ backgroundColor: deletingColor?.hex_code }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{deletingColor?.hex_code}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
この操作は取り消すことができません。
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
キャンセル
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
onPress={handleDelete}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
削除する
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
466
src/pages/dashboard/Dashboard.tsx
Normal file
466
src/pages/dashboard/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
import { Card, CardBody, CardHeader, Chip, Spinner, Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure, Input } from '@heroui/react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useItems, useLoans } from '@/hooks'
|
||||||
|
import { useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { QrCode } from 'lucide-react'
|
||||||
|
import { BrowserMultiFormatReader, NotFoundException } from '@zxing/library'
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||||
|
const [searchId, setSearchId] = useState('')
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null)
|
||||||
|
|
||||||
|
// データ取得
|
||||||
|
const { data: itemsData, isLoading: isLoadingItems } = useItems({
|
||||||
|
page: 1,
|
||||||
|
per_page: 1000
|
||||||
|
})
|
||||||
|
const { data: loansData, isLoading: isLoadingLoans } = useLoans({
|
||||||
|
page: 1,
|
||||||
|
per_page: 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = itemsData?.data || []
|
||||||
|
const loans = loansData?.data || []
|
||||||
|
|
||||||
|
// 統計データの計算
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (isLoadingItems || isLoadingLoans) {
|
||||||
|
return [
|
||||||
|
{ label: '総備品数', value: '-', color: 'primary' },
|
||||||
|
{ label: '貸出中', value: '-', color: 'warning' },
|
||||||
|
{ label: '今月の新規貸出', value: '-', color: 'success' },
|
||||||
|
{ label: '廃棄済み', value: '-', color: 'danger' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 総備品数
|
||||||
|
const totalItems = items.length
|
||||||
|
|
||||||
|
// 貸出中の備品数
|
||||||
|
const onLoanItems = items.filter(item => item.is_on_loan).length
|
||||||
|
|
||||||
|
// 廃棄済み備品数
|
||||||
|
const disposedItems = items.filter(item => item.is_disposed).length
|
||||||
|
|
||||||
|
// 今月の新規貸出数
|
||||||
|
const currentMonth = new Date().getMonth()
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const thisMonthLoans = loans.filter(loan => {
|
||||||
|
const loanDate = new Date(loan.loan_date)
|
||||||
|
return loanDate.getMonth() === currentMonth && loanDate.getFullYear() === currentYear
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '総備品数', value: totalItems.toString(), color: 'primary' },
|
||||||
|
{ label: '貸出中', value: onLoanItems.toString(), color: 'warning' },
|
||||||
|
{ label: '今月の新規貸出', value: thisMonthLoans.toString(), color: 'success' },
|
||||||
|
{ label: '廃棄済み', value: disposedItems.toString(), color: 'danger' },
|
||||||
|
]
|
||||||
|
}, [items, loans, isLoadingItems, isLoadingLoans])
|
||||||
|
|
||||||
|
// 最近の貸出活動
|
||||||
|
const recentLoans = useMemo(() => {
|
||||||
|
return loans
|
||||||
|
.sort((a, b) => new Date(b.loan_date).getTime() - new Date(a.loan_date).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
}, [loans])
|
||||||
|
|
||||||
|
// IDで備品を検索
|
||||||
|
const searchItemById = useCallback((id: string) => {
|
||||||
|
const trimmedId = id.trim()
|
||||||
|
if (!trimmedId) {
|
||||||
|
setSearchError('IDを入力してください')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ラベルIDで検索
|
||||||
|
const item = items.find(item => item.label_id === trimmedId)
|
||||||
|
if (item) {
|
||||||
|
stopCamera()
|
||||||
|
navigate(`/items/${item.id}`)
|
||||||
|
onOpenChange()
|
||||||
|
setSearchId('')
|
||||||
|
setSearchError('')
|
||||||
|
} else {
|
||||||
|
setSearchError('該当する備品が見つかりません')
|
||||||
|
}
|
||||||
|
}, [items, navigate, onOpenChange])
|
||||||
|
|
||||||
|
// QRコード読み取り成功時のハンドラ
|
||||||
|
const handleCodeDetected = useCallback((result: string) => {
|
||||||
|
console.log('QRコード読み取り成功:', result)
|
||||||
|
setSearchId(result)
|
||||||
|
searchItemById(result)
|
||||||
|
}, [searchItemById])
|
||||||
|
|
||||||
|
// カメラ開始
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
setIsScanning(true)
|
||||||
|
setSearchError('')
|
||||||
|
console.log('カメラ起動開始...')
|
||||||
|
|
||||||
|
const videoElement = videoRef.current
|
||||||
|
if (!videoElement) {
|
||||||
|
throw new Error('Video element not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS環境かチェック
|
||||||
|
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||||
|
throw new Error('カメラアクセスにはHTTPS環境が必要です')
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigator.mediaDevices が利用可能かチェック
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
throw new Error('このブラウザはカメラアクセスをサポートしていません')
|
||||||
|
}
|
||||||
|
|
||||||
|
// まず基本的なカメラアクセスを試行
|
||||||
|
console.log('カメラアクセス許可を要求中...')
|
||||||
|
let stream: MediaStream
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 後面カメラを優先
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment',
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (backCameraError) {
|
||||||
|
console.warn('後面カメラの取得に失敗、前面カメラを試行:', backCameraError)
|
||||||
|
// 前面カメラまたは任意のカメラを試行
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('カメラアクセス成功、ビデオ要素にストリームを設定中...')
|
||||||
|
videoElement.srcObject = stream
|
||||||
|
|
||||||
|
// ビデオが再生可能になるまで待機
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
videoElement.onloadedmetadata = () => {
|
||||||
|
console.log('ビデオメタデータ読み込み完了')
|
||||||
|
resolve(void 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ZXing code reader を初期化
|
||||||
|
console.log('ZXing code reader を初期化中...')
|
||||||
|
if (!codeReaderRef.current) {
|
||||||
|
codeReaderRef.current = new BrowserMultiFormatReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRコード読み取り開始
|
||||||
|
console.log('QRコード読み取り開始...')
|
||||||
|
|
||||||
|
// 継続的な読み取りのためのループ
|
||||||
|
const scan = () => {
|
||||||
|
if (!isScanning || !codeReaderRef.current || !videoElement.srcObject) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ビデオの準備ができているかチェック
|
||||||
|
if (videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
|
||||||
|
return // ビデオがまだ準備できていない
|
||||||
|
}
|
||||||
|
|
||||||
|
// キャンバスを使用してビデオフレームをキャプチャ
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) return
|
||||||
|
|
||||||
|
canvas.width = videoElement.videoWidth
|
||||||
|
canvas.height = videoElement.videoHeight
|
||||||
|
context.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Canvas から直接読み取り
|
||||||
|
const result = codeReaderRef.current.decode(canvas)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
console.log('QRコード読み取り成功:', result.getText())
|
||||||
|
handleCodeDetected(result.getText())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof NotFoundException)) {
|
||||||
|
console.warn('QRコード読み取りエラー:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 継続的に読み取りを試行
|
||||||
|
if (isScanning) {
|
||||||
|
setTimeout(scan, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ビデオが再生されてから読み取り開始
|
||||||
|
videoElement.addEventListener('playing', () => {
|
||||||
|
console.log('ビデオ再生開始、スキャン開始')
|
||||||
|
scan()
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('カメラ起動完了')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('カメラの起動に失敗しました:', error)
|
||||||
|
let errorMessage = 'カメラの起動に失敗しました。'
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
errorMessage = 'カメラのアクセス許可が拒否されました。ブラウザの設定でカメラを許可してください。'
|
||||||
|
} else if (error.name === 'NotFoundError') {
|
||||||
|
errorMessage = 'カメラが見つかりません。'
|
||||||
|
} else if (error.name === 'NotReadableError') {
|
||||||
|
errorMessage = 'カメラが他のアプリで使用中です。'
|
||||||
|
} else {
|
||||||
|
errorMessage += ` エラー: ${error.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchError(errorMessage)
|
||||||
|
setIsScanning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// カメラ停止
|
||||||
|
const stopCamera = useCallback(() => {
|
||||||
|
if (codeReaderRef.current) {
|
||||||
|
codeReaderRef.current.reset()
|
||||||
|
}
|
||||||
|
if (videoRef.current?.srcObject) {
|
||||||
|
const stream = videoRef.current.srcObject as MediaStream
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
videoRef.current.srcObject = null
|
||||||
|
}
|
||||||
|
setIsScanning(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// モーダルが閉じられた時の処理
|
||||||
|
const handleModalClose = useCallback(() => {
|
||||||
|
stopCamera()
|
||||||
|
setSearchId('')
|
||||||
|
setSearchError('')
|
||||||
|
onOpenChange()
|
||||||
|
}, [stopCamera, onOpenChange])
|
||||||
|
|
||||||
|
// コンポーネントのアンマウント時の処理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCamera()
|
||||||
|
}
|
||||||
|
}, [stopCamera])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold mb-8">ダッシュボード</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<Card key={index} className="shadow-sm">
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-sm text-gray-600">{stat.label}</p>
|
||||||
|
<p className={`text-3xl font-bold text-${stat.color}`}>{stat.value}</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<h2 className="text-xl font-semibold">クイックアクション</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
to="/items/new"
|
||||||
|
className="block p-4 rounded-lg border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">新規備品登録</h3>
|
||||||
|
<p className="text-sm text-gray-600">新しい備品を登録します</p>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/items"
|
||||||
|
className="block p-4 rounded-lg border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">備品一覧</h3>
|
||||||
|
<p className="text-sm text-gray-600">すべての備品を確認・管理します</p>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/loans"
|
||||||
|
className="block p-4 rounded-lg border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">貸出管理</h3>
|
||||||
|
<p className="text-sm text-gray-600">貸出履歴と返却管理を行います</p>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
className="block w-full p-4 rounded-lg border hover:bg-gray-50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium flex items-center gap-2">
|
||||||
|
<QrCode size={20} />
|
||||||
|
QR/バーコード読み取り
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">QRコードやバーコードから備品を検索します</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<h2 className="text-xl font-semibold">最近の貸出</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
{isLoadingLoans ? (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<Spinner label="読み込み中..." />
|
||||||
|
</div>
|
||||||
|
) : recentLoans.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recentLoans.map((loan) => (
|
||||||
|
<div key={loan.id} className="flex justify-between items-center p-3 rounded-lg border">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
{loan.item?.name || `備品ID: ${loan.item_id}`}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{loan.student_name} ({loan.student_number})
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(loan.loan_date).toLocaleDateString('ja-JP')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Chip
|
||||||
|
color={loan.return_date ? 'success' : 'warning'}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{loan.return_date ? '返却済み' : '貸出中'}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Link
|
||||||
|
to="/loans"
|
||||||
|
className="block text-center text-sm text-primary hover:underline mt-4"
|
||||||
|
>
|
||||||
|
すべての貸出を見る
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-center py-8">最近の貸出はありません</p>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR/バーコード読み取りモーダル */}
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={handleModalClose} size="lg">
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
QR/バーコード読み取り
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 手動入力 */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="ラベルID"
|
||||||
|
placeholder="備品のラベルIDを入力してください"
|
||||||
|
value={searchId}
|
||||||
|
onValueChange={setSearchId}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
searchItemById(searchId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
errorMessage={searchError}
|
||||||
|
isInvalid={!!searchError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* カメラ読み取り */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
onPress={startCamera}
|
||||||
|
isDisabled={isScanning}
|
||||||
|
>
|
||||||
|
カメラで読み取り
|
||||||
|
</Button>
|
||||||
|
{isScanning && (
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
onPress={stopCamera}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isScanning && (
|
||||||
|
<div className="relative">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="w-full h-64 bg-black rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-48 h-48 border-2 border-white border-dashed rounded-lg opacity-50"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-2 text-center">
|
||||||
|
QRコードまたはバーコードを枠内に合わせてください
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
読み取りが成功すると自動的に検索されます
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p>• 備品のラベルIDを直接入力するか、カメラでQRコード/バーコードを読み取ってください</p>
|
||||||
|
<p>• 読み取り後、該当する備品の詳細画面に移動します</p>
|
||||||
|
{location.protocol !== 'https:' && location.hostname !== 'localhost' && (
|
||||||
|
<p className="text-warning mt-2">⚠️ カメラ機能を使用するにはHTTPS環境が必要です</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
キャンセル
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={() => searchItemById(searchId)}
|
||||||
|
isDisabled={!searchId.trim()}
|
||||||
|
>
|
||||||
|
検索
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
307
src/pages/items/ItemDetail.tsx
Normal file
307
src/pages/items/ItemDetail.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Button, Card, CardBody, CardHeader, Chip, Image, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from '@heroui/react'
|
||||||
|
import { ArrowLeft, Edit, Trash2 } from 'lucide-react'
|
||||||
|
import { useItem, useDeleteItem } from '@/hooks'
|
||||||
|
import { CableVisualization } from '@/components/ui/CableVisualization'
|
||||||
|
|
||||||
|
export function ItemDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const itemId = id ? Number(id) : 0
|
||||||
|
|
||||||
|
const { data: item, isLoading, error } = useItem(itemId)
|
||||||
|
const deleteItemMutation = useDeleteItem()
|
||||||
|
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-96">
|
||||||
|
<Spinner label="データを読み込み中..." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-danger">
|
||||||
|
エラーが発生しました: {(error as any)?.message || '不明なエラー'}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-gray-500">備品が見つかりません</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
>
|
||||||
|
一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to={`/items/${id}/edit`}
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
startContent={<Edit size={16} />}
|
||||||
|
>
|
||||||
|
編集
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="flat"
|
||||||
|
startContent={<Trash2 size={16} />}
|
||||||
|
onPress={onOpen}
|
||||||
|
>
|
||||||
|
削除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h1 className="text-2xl font-bold">{item.name}</h1>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">ラベルID</dt>
|
||||||
|
<dd className="font-medium">{item.label_id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">型番</dt>
|
||||||
|
<dd className="font-medium">{item.model_number || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">購入年</dt>
|
||||||
|
<dd className="font-medium">{item.purchase_year || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">購入金額</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{item.purchase_amount ? `¥${item.purchase_amount.toLocaleString()}` : '-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">耐用年数</dt>
|
||||||
|
<dd className="font-medium">{item.durability_years ? `${item.durability_years}年` : '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">減価償却対象</dt>
|
||||||
|
<dd className="font-medium">{item.is_depreciation_target ? 'はい' : 'いいえ'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">QRコードタイプ</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{item.qr_code_type === 'qr' ? 'QRコード' :
|
||||||
|
item.qr_code_type === 'barcode' ? 'バーコード' : 'なし'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">ステータス</dt>
|
||||||
|
<dd>
|
||||||
|
{item.is_disposed ? (
|
||||||
|
<Chip color="danger" size="sm">廃棄済み</Chip>
|
||||||
|
) : item.is_on_loan ? (
|
||||||
|
<Chip color="warning" size="sm">貸出中</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip color="success" size="sm">利用可能</Chip>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{item.storage_locations?.length ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<dt className="text-sm text-gray-600">保管場所</dt>
|
||||||
|
<dd className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{item.storage_locations.map((location, index) => (
|
||||||
|
<Chip key={index} variant="flat" color="success" size="sm">
|
||||||
|
{location}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{item.remarks && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">備考</h3>
|
||||||
|
<p className="whitespace-pre-wrap bg-gray-50 p-3 rounded-lg">{item.remarks}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(item.connection_names?.length || item.cable_color_pattern?.length || item.storage_locations?.length) ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-xl font-semibold">接続・配線情報</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<dl className="space-y-4">
|
||||||
|
{item.connection_names?.length ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600 mb-2">接続名称</dt>
|
||||||
|
<dd className="flex flex-wrap gap-2">
|
||||||
|
{item.connection_names.map((name, index) => (
|
||||||
|
<Chip key={index} variant="flat" color="primary" size="sm">
|
||||||
|
{name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.cable_color_pattern?.length ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600 mb-2">ケーブル色パターン</dt>
|
||||||
|
<dd className="space-y-2">
|
||||||
|
<CableVisualization
|
||||||
|
colorNames={item.cable_color_pattern}
|
||||||
|
size="lg"
|
||||||
|
showLabels={false}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
端子側から順に: {item.cable_color_pattern?.map((name, index) => (
|
||||||
|
<span key={index}>
|
||||||
|
{index + 1}.{name}
|
||||||
|
{index < (item.cable_color_pattern?.length || 0) - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{item.image_url && (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Image
|
||||||
|
src={item.image_url}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-lg font-semibold">詳細情報</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">QRコードタイプ</dt>
|
||||||
|
<dd className="font-medium">{item.qr_code_type || 'なし'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">登録日時</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{new Date(item.created_at).toLocaleString('ja-JP')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-600">更新日時</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{new Date(item.updated_at).toLocaleString('ja-JP')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
備品の削除確認
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>
|
||||||
|
「<strong>{item?.name}</strong>」を削除しますか?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
この操作は取り消すことができません。
|
||||||
|
</p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={onClose}>
|
||||||
|
キャンセル
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await deleteItemMutation.mutateAsync(itemId)
|
||||||
|
onClose()
|
||||||
|
navigate('/items')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error:', error)
|
||||||
|
// TODO: Show error message to user
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={deleteItemMutation.isPending}
|
||||||
|
>
|
||||||
|
削除する
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
527
src/pages/items/ItemForm.tsx
Normal file
527
src/pages/items/ItemForm.tsx
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Switch,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Spinner,
|
||||||
|
Snippet,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react'
|
||||||
|
import { Item } from '@/types'
|
||||||
|
import { useItem, useCreateItem, useUpdateItem, useItemSuggestions } from '@/hooks'
|
||||||
|
import { ArrayInput } from '@/components/ui/ArrayInput'
|
||||||
|
import { CableColorInput } from '@/components/ui/CableColorInput'
|
||||||
|
import { ImageUpload } from '@/components/ui/ImageUpload'
|
||||||
|
|
||||||
|
type ItemFormData = Omit<Item, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
|
||||||
|
export function ItemForm() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isEdit = !!id
|
||||||
|
const itemId = id ? Number(id) : undefined
|
||||||
|
|
||||||
|
// Fetch item data for edit mode
|
||||||
|
const { data: item, isLoading: isLoadingItem, error: itemError } = useItem(itemId || 0)
|
||||||
|
|
||||||
|
// Fetch suggestions for array fields
|
||||||
|
const { data: connectionSuggestions = [] } = useItemSuggestions('connection_names')
|
||||||
|
const { data: locationSuggestions = [] } = useItemSuggestions('storage_locations')
|
||||||
|
|
||||||
|
// Debug logs (development only)
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log('ItemForm Debug:', {
|
||||||
|
id,
|
||||||
|
itemId,
|
||||||
|
isEdit,
|
||||||
|
item,
|
||||||
|
isLoadingItem,
|
||||||
|
itemError
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createItemMutation = useCreateItem()
|
||||||
|
const updateItemMutation = useUpdateItem()
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ItemFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
label_id: '',
|
||||||
|
model_number: '',
|
||||||
|
remarks: '',
|
||||||
|
purchase_year: undefined,
|
||||||
|
purchase_amount: undefined,
|
||||||
|
durability_years: undefined,
|
||||||
|
is_depreciation_target: false,
|
||||||
|
connection_names: [],
|
||||||
|
cable_color_pattern: [],
|
||||||
|
storage_locations: [],
|
||||||
|
qr_code_type: 'none',
|
||||||
|
is_disposed: false,
|
||||||
|
image_url: '',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get current form values
|
||||||
|
const formValues = watch()
|
||||||
|
|
||||||
|
// Debug: Watch form values (development only)
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log('Form values:', formValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form with item data when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && item) {
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log('Setting form values with item data:', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set values individually to ensure they're applied
|
||||||
|
setValue('name', item.name || '')
|
||||||
|
setValue('label_id', item.label_id || '')
|
||||||
|
setValue('model_number', item.model_number || '')
|
||||||
|
setValue('remarks', item.remarks || '')
|
||||||
|
setValue('purchase_year', item.purchase_year)
|
||||||
|
setValue('purchase_amount', item.purchase_amount)
|
||||||
|
setValue('durability_years', item.durability_years)
|
||||||
|
setValue('is_depreciation_target', !!item.is_depreciation_target)
|
||||||
|
setValue('connection_names', item.connection_names || [])
|
||||||
|
setValue('cable_color_pattern', item.cable_color_pattern || [])
|
||||||
|
setValue('storage_locations', item.storage_locations || [])
|
||||||
|
setValue('qr_code_type', item.qr_code_type || 'none')
|
||||||
|
setValue('is_disposed', !!item.is_disposed)
|
||||||
|
setValue('image_url', item.image_url || '')
|
||||||
|
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log('Form values set individually')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [item, isEdit, setValue])
|
||||||
|
|
||||||
|
const onSubmit = async (data: ItemFormData) => {
|
||||||
|
try {
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!data.name?.trim()) {
|
||||||
|
setSubmitError('備品名は必須です。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.label_id?.trim()) {
|
||||||
|
setSubmitError('ラベルIDは必須です。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and transform data for API
|
||||||
|
const cleanedData = {
|
||||||
|
// Required fields - ensure they are present
|
||||||
|
name: data.name?.trim() || '',
|
||||||
|
label_id: data.label_id?.trim() || '',
|
||||||
|
// Optional string fields
|
||||||
|
model_number: data.model_number?.trim() || undefined,
|
||||||
|
remarks: data.remarks?.trim() || undefined,
|
||||||
|
// Numeric fields - only include if they have valid values
|
||||||
|
...(data.purchase_year && !isNaN(Number(data.purchase_year)) && { purchase_year: Number(data.purchase_year) }),
|
||||||
|
...(data.purchase_amount && !isNaN(Number(data.purchase_amount)) && { purchase_amount: Number(data.purchase_amount) }),
|
||||||
|
...(data.durability_years && !isNaN(Number(data.durability_years)) && { durability_years: Number(data.durability_years) }),
|
||||||
|
// Boolean fields - provide defaults
|
||||||
|
is_depreciation_target: Boolean(data.is_depreciation_target),
|
||||||
|
is_disposed: Boolean(data.is_disposed),
|
||||||
|
// QR code type - ensure valid value
|
||||||
|
qr_code_type: data.qr_code_type || 'none',
|
||||||
|
// Array fields - ensure they are arrays
|
||||||
|
connection_names: Array.isArray(data.connection_names) ? data.connection_names.filter(Boolean) : [],
|
||||||
|
cable_color_pattern: Array.isArray(data.cable_color_pattern) ? data.cable_color_pattern.filter(Boolean) : [],
|
||||||
|
storage_locations: Array.isArray(data.storage_locations) ? data.storage_locations.filter(Boolean) : [],
|
||||||
|
// Image URL - only include if not empty
|
||||||
|
...(data.image_url?.trim() && { image_url: data.image_url.trim() }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log('Original form data:', data)
|
||||||
|
console.log('Cleaned data for API:', cleanedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && itemId) {
|
||||||
|
await updateItemMutation.mutateAsync({ id: itemId, data: cleanedData })
|
||||||
|
} else {
|
||||||
|
// For creation, only send non-undefined fields
|
||||||
|
const createData = Object.fromEntries(
|
||||||
|
Object.entries(cleanedData).filter(([_, value]) => value !== undefined)
|
||||||
|
)
|
||||||
|
|
||||||
|
await createItemMutation.mutateAsync(createData as Omit<Item, 'id' | 'created_at' | 'updated_at'>)
|
||||||
|
}
|
||||||
|
navigate('/items')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Form submission error:', error)
|
||||||
|
|
||||||
|
let errorMessage = '保存に失敗しました。'
|
||||||
|
|
||||||
|
if (error?.response?.status === 400) {
|
||||||
|
errorMessage = 'リクエストデータに問題があります。入力内容を確認してください。'
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
errorMessage += ` (${error.response.data.message})`
|
||||||
|
}
|
||||||
|
} else if (error?.response?.status === 404) {
|
||||||
|
errorMessage = '指定された備品が見つかりません。'
|
||||||
|
} else if (error?.response?.status === 500) {
|
||||||
|
errorMessage = 'サーバーエラーが発生しました。管理者に連絡してください。'
|
||||||
|
} else if (error?.message?.includes('Network Error')) {
|
||||||
|
errorMessage = 'ネットワーク接続を確認してください。'
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitError(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state for edit mode
|
||||||
|
if (isEdit && isLoadingItem) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-96">
|
||||||
|
<Spinner label="データを読み込み中..." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state for edit mode
|
||||||
|
if (isEdit && itemError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-danger">
|
||||||
|
備品データの読み込みに失敗しました: {(itemError as any)?.message || '不明なエラー'}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item not found for edit mode
|
||||||
|
if (isEdit && !isLoadingItem && !item) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-warning">
|
||||||
|
指定された備品が見つかりません
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
>
|
||||||
|
一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{isEdit ? '備品編集' : '備品登録'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-xl font-semibold">基本情報</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
{...register('name', { required: '備品名は必須です' })}
|
||||||
|
label="備品名"
|
||||||
|
placeholder="例: ノートパソコン"
|
||||||
|
errorMessage={errors.name?.message}
|
||||||
|
isInvalid={!!errors.name}
|
||||||
|
isRequired
|
||||||
|
value={formValues.name || ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('label_id', { required: 'ラベルIDは必須です' })}
|
||||||
|
label="ラベルID"
|
||||||
|
placeholder="例: PC-001"
|
||||||
|
errorMessage={errors.label_id?.message}
|
||||||
|
isInvalid={!!errors.label_id}
|
||||||
|
isRequired
|
||||||
|
value={formValues.label_id || ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('model_number')}
|
||||||
|
label="型番"
|
||||||
|
placeholder="例: MacBook Pro 13inch"
|
||||||
|
value={formValues.model_number || ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('purchase_year', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
setValueAs: (value) => value === '' ? undefined : Number(value)
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
label="購入年"
|
||||||
|
placeholder="例: 2023"
|
||||||
|
value={formValues.purchase_year?.toString() || ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('purchase_amount', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
setValueAs: (value) => value === '' ? undefined : Number(value)
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
label="購入金額"
|
||||||
|
placeholder="例: 150000"
|
||||||
|
startContent={<span className="text-default-400 text-sm">¥</span>}
|
||||||
|
value={formValues.purchase_amount?.toString() || ''}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('durability_years', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
setValueAs: (value) => value === '' ? undefined : Number(value)
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
label="耐用年数"
|
||||||
|
placeholder="例: 5"
|
||||||
|
endContent={<span className="text-default-400 text-sm">年</span>}
|
||||||
|
value={formValues.durability_years?.toString() || ''}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="qr_code_type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
label="QRコードタイプ"
|
||||||
|
placeholder="選択してください"
|
||||||
|
selectedKeys={formValues.qr_code_type ? [formValues.qr_code_type] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const value = Array.from(keys)[0] as 'qr' | 'barcode' | 'none'
|
||||||
|
field.onChange(value)
|
||||||
|
setValue('qr_code_type', value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="none">なし</SelectItem>
|
||||||
|
<SelectItem key="qr">QRコード</SelectItem>
|
||||||
|
<SelectItem key="barcode">バーコード</SelectItem>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Controller
|
||||||
|
name="storage_locations"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ArrayInput
|
||||||
|
label="保管場所"
|
||||||
|
placeholder="例: A棟201教室、機材庫"
|
||||||
|
values={formValues.storage_locations || []}
|
||||||
|
onChange={(values) => {
|
||||||
|
field.onChange(values)
|
||||||
|
setValue('storage_locations', values)
|
||||||
|
}}
|
||||||
|
maxItems={5}
|
||||||
|
suggestions={locationSuggestions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Controller
|
||||||
|
name="is_depreciation_target"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
isSelected={formValues.is_depreciation_target || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value)
|
||||||
|
setValue('is_depreciation_target', value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
減価償却対象
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="is_disposed"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
isSelected={formValues.is_disposed || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value)
|
||||||
|
setValue('is_disposed', value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
廃棄済み
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Textarea
|
||||||
|
{...register('remarks')}
|
||||||
|
label="備考"
|
||||||
|
placeholder="備品に関する追加情報を入力してください"
|
||||||
|
rows={4}
|
||||||
|
value={formValues.remarks || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-xl font-semibold">接続・配線情報</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<Controller
|
||||||
|
name="connection_names"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ArrayInput
|
||||||
|
label="接続名称"
|
||||||
|
placeholder="例: HDMI、USB-C、電源"
|
||||||
|
values={formValues.connection_names || []}
|
||||||
|
onChange={(values) => {
|
||||||
|
field.onChange(values)
|
||||||
|
setValue('connection_names', values)
|
||||||
|
}}
|
||||||
|
maxItems={20}
|
||||||
|
suggestions={connectionSuggestions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="cable_color_pattern"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CableColorInput
|
||||||
|
label="ケーブル色パターン"
|
||||||
|
values={formValues.cable_color_pattern || []}
|
||||||
|
onChange={(values) => {
|
||||||
|
field.onChange(values)
|
||||||
|
setValue('cable_color_pattern', values)
|
||||||
|
}}
|
||||||
|
maxItems={10}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-xl font-semibold">画像</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Controller
|
||||||
|
name="image_url"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ImageUpload
|
||||||
|
currentImageUrl={formValues.image_url || undefined}
|
||||||
|
onImageChange={(imageUrl) => {
|
||||||
|
field.onChange(imageUrl || '')
|
||||||
|
setValue('image_url', imageUrl || '')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(submitError || Object.keys(errors).length > 0) && (
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardBody>
|
||||||
|
{submitError && (
|
||||||
|
<Snippet color="danger" variant="flat" symbol="⚠️" className="mb-2">
|
||||||
|
{submitError}
|
||||||
|
</Snippet>
|
||||||
|
)}
|
||||||
|
{Object.keys(errors).length > 0 && (
|
||||||
|
<Snippet color="warning" variant="flat" symbol="⚠️">
|
||||||
|
入力に不備があります。必須項目を確認してください。
|
||||||
|
</Snippet>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4 mt-6">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="flat"
|
||||||
|
isDisabled={createItemMutation.isPending || updateItemMutation.isPending}
|
||||||
|
>
|
||||||
|
キャンセル
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
startContent={<Save size={16} />}
|
||||||
|
isLoading={createItemMutation.isPending || updateItemMutation.isPending}
|
||||||
|
>
|
||||||
|
{isEdit ? '更新' : '登録'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
651
src/pages/items/ItemsList.tsx
Normal file
651
src/pages/items/ItemsList.tsx
Normal file
|
|
@ -0,0 +1,651 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableColumn,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Chip,
|
||||||
|
Pagination,
|
||||||
|
Spinner,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Dropdown,
|
||||||
|
DropdownTrigger,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
Checkbox,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
SortDescriptor,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { Search, Plus, Eye, Edit, Settings, Trash2, RotateCcw, Users, Undo } from 'lucide-react'
|
||||||
|
import { Item } from '@/types'
|
||||||
|
import { useItems, useDisposeItem, useUndisposeItem, useReturnItem } from '@/hooks'
|
||||||
|
import { CableVisualization } from '@/components/ui/CableVisualization'
|
||||||
|
|
||||||
|
export function ItemsList() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState(new Set([
|
||||||
|
'label_id', 'name', 'model_number', 'purchase_year', 'cable_colors', 'storage_locations', 'status', 'actions'
|
||||||
|
]))
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||||
|
column: 'created_at',
|
||||||
|
direction: 'descending',
|
||||||
|
})
|
||||||
|
|
||||||
|
// フィルタ状態
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'available' | 'on_loan' | 'disposed'>('all')
|
||||||
|
const [qrCodeFilter, setQrCodeFilter] = useState<'all' | 'qr' | 'barcode' | 'none'>('all')
|
||||||
|
const [depreciationFilter, setDepreciationFilter] = useState<'all' | 'target' | 'not_target'>('all')
|
||||||
|
const [purchaseYearFrom, setPurchaseYearFrom] = useState('')
|
||||||
|
const [purchaseYearTo, setPurchaseYearTo] = useState('')
|
||||||
|
|
||||||
|
const disposeItemMutation = useDisposeItem()
|
||||||
|
const undisposeItemMutation = useUndisposeItem()
|
||||||
|
const returnItemMutation = useReturnItem()
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useItems({
|
||||||
|
page: 1, // クライアント側でページネーションを行うため、全データを取得
|
||||||
|
per_page: 1000, // 大きな値で全データを取得
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const allItems = data?.data || []
|
||||||
|
|
||||||
|
// フィルタリング処理
|
||||||
|
const filteredItems = allItems.filter(item => {
|
||||||
|
// ステータスフィルタ
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
if (statusFilter === 'available' && (item.is_disposed || item.is_on_loan)) return false
|
||||||
|
if (statusFilter === 'on_loan' && !item.is_on_loan) return false
|
||||||
|
if (statusFilter === 'disposed' && !item.is_disposed) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRコードフィルタ
|
||||||
|
if (qrCodeFilter !== 'all') {
|
||||||
|
const qrType = item.qr_code_type || 'none'
|
||||||
|
if (qrCodeFilter !== qrType) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 減価償却フィルタ
|
||||||
|
if (depreciationFilter !== 'all') {
|
||||||
|
if (depreciationFilter === 'target' && !item.is_depreciation_target) return false
|
||||||
|
if (depreciationFilter === 'not_target' && item.is_depreciation_target) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 購入年フィルタ
|
||||||
|
if (purchaseYearFrom && item.purchase_year && item.purchase_year < parseInt(purchaseYearFrom)) return false
|
||||||
|
if (purchaseYearTo && item.purchase_year && item.purchase_year > parseInt(purchaseYearTo)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// ソート処理
|
||||||
|
const sortedItems = [...filteredItems].sort((a, b) => {
|
||||||
|
const { column, direction } = sortDescriptor
|
||||||
|
let aValue: any = a[column as keyof Item]
|
||||||
|
let bValue: any = b[column as keyof Item]
|
||||||
|
|
||||||
|
// 文字列の場合
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
aValue = aValue.toLowerCase()
|
||||||
|
bValue = bValue.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数値の場合
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return direction === 'ascending' ? aValue - bValue : bValue - aValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日付の場合
|
||||||
|
if (column === 'created_at' || column === 'updated_at') {
|
||||||
|
aValue = new Date(aValue).getTime()
|
||||||
|
bValue = new Date(bValue).getTime()
|
||||||
|
return direction === 'ascending' ? aValue - bValue : bValue - aValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字列比較
|
||||||
|
if (aValue < bValue) return direction === 'ascending' ? -1 : 1
|
||||||
|
if (aValue > bValue) return direction === 'ascending' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ページネーション処理
|
||||||
|
const itemsPerPage = 20
|
||||||
|
const totalFilteredItems = sortedItems.length
|
||||||
|
const totalPages = Math.ceil(totalFilteredItems / itemsPerPage)
|
||||||
|
const startIndex = (page - 1) * itemsPerPage
|
||||||
|
const endIndex = startIndex + itemsPerPage
|
||||||
|
const items = sortedItems.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'image', label: '画像', sortable: false },
|
||||||
|
{ key: 'label_id', label: 'ラベルID', sortable: true },
|
||||||
|
{ key: 'name', label: '備品名', sortable: true },
|
||||||
|
{ key: 'model_number', label: '型番', sortable: true },
|
||||||
|
{ key: 'purchase_info', label: '購入情報', sortable: false },
|
||||||
|
{ key: 'purchase_year', label: '購入年', sortable: true },
|
||||||
|
{ key: 'purchase_amount', label: '購入金額', sortable: true },
|
||||||
|
{ key: 'durability_years', label: '耐用年数', sortable: true },
|
||||||
|
{ key: 'connections', label: '接続', sortable: false },
|
||||||
|
{ key: 'cable_colors', label: 'ケーブル色', sortable: false },
|
||||||
|
{ key: 'storage_locations', label: '保管場所', sortable: false },
|
||||||
|
{ key: 'qr_code_type', label: 'QRコード', sortable: true },
|
||||||
|
{ key: 'depreciation', label: '減価償却', sortable: true },
|
||||||
|
{ key: 'status', label: 'ステータス', sortable: false },
|
||||||
|
{ key: 'created_at', label: '作成日', sortable: true },
|
||||||
|
{ key: 'updated_at', label: '更新日', sortable: true },
|
||||||
|
{ key: 'actions', label: '操作', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = allColumns.filter(col => visibleColumns.has(col.key) || col.key === 'actions')
|
||||||
|
|
||||||
|
const toggleColumnVisibility = (columnKey: string) => {
|
||||||
|
const newVisibleColumns = new Set(visibleColumns)
|
||||||
|
if (newVisibleColumns.has(columnKey)) {
|
||||||
|
newVisibleColumns.delete(columnKey)
|
||||||
|
} else {
|
||||||
|
newVisibleColumns.add(columnKey)
|
||||||
|
}
|
||||||
|
setVisibleColumns(newVisibleColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setStatusFilter('all')
|
||||||
|
setQrCodeFilter('all')
|
||||||
|
setDepreciationFilter('all')
|
||||||
|
setPurchaseYearFrom('')
|
||||||
|
setPurchaseYearTo('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReturnItem = async (itemId: number) => {
|
||||||
|
try {
|
||||||
|
// Get active loan for this item using the loans service
|
||||||
|
const loansResponse = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080/api/v1'}/loans?item_id=${itemId}&status=active`)
|
||||||
|
if (loansResponse.ok) {
|
||||||
|
const loansData = await loansResponse.json()
|
||||||
|
const activeLoans = loansData.loans || loansData.data || []
|
||||||
|
const activeLoan = activeLoans.find((loan: any) => !loan.return_date)
|
||||||
|
|
||||||
|
if (activeLoan?.id) {
|
||||||
|
await returnItemMutation.mutateAsync({
|
||||||
|
id: activeLoan.id,
|
||||||
|
data: {
|
||||||
|
remarks: '一覧画面から返却'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Return item error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const renderCell = (item: Item, columnKey: React.Key) => {
|
||||||
|
switch (columnKey) {
|
||||||
|
case 'image':
|
||||||
|
return item.image_url ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={item.image_url}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-12 h-12 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-gray-400 text-xs">画像なし</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'purchase_info':
|
||||||
|
const purchaseInfo = []
|
||||||
|
if (item.purchase_year) purchaseInfo.push(`${item.purchase_year}年`)
|
||||||
|
if (item.purchase_amount) purchaseInfo.push(`¥${item.purchase_amount.toLocaleString()}`)
|
||||||
|
return purchaseInfo.length > 0 ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
{purchaseInfo.map((info, index) => (
|
||||||
|
<div key={index}>{info}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : '-'
|
||||||
|
|
||||||
|
case 'durability_years':
|
||||||
|
return item.durability_years ? `${item.durability_years}年` : '-'
|
||||||
|
|
||||||
|
case 'cable_colors':
|
||||||
|
return item.cable_color_pattern?.length ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<CableVisualization
|
||||||
|
colorNames={item.cable_color_pattern}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : '-'
|
||||||
|
|
||||||
|
case 'connections':
|
||||||
|
const connections = item.connection_names?.slice(0, 2) || []
|
||||||
|
return connections.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{connections.map((conn, index) => (
|
||||||
|
<Chip key={index} size="sm" variant="flat" color="primary">
|
||||||
|
{conn}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
{(item.connection_names?.length || 0) > 2 && (
|
||||||
|
<Chip size="sm" variant="flat">+{(item.connection_names?.length || 0) - 2}</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : '-'
|
||||||
|
|
||||||
|
case 'storage_locations':
|
||||||
|
return item.storage_locations?.length ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.storage_locations.slice(0, 2).map((location, index) => (
|
||||||
|
<Chip key={index} size="sm" variant="flat" color="success">
|
||||||
|
{location}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
{item.storage_locations.length > 2 && (
|
||||||
|
<Chip size="sm" variant="flat">+{item.storage_locations.length - 2}</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : '-'
|
||||||
|
|
||||||
|
case 'qr_code_type':
|
||||||
|
if (!item.qr_code_type || item.qr_code_type === 'none') {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={item.qr_code_type === 'qr' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{item.qr_code_type === 'qr' ? 'QR' : 'バーコード'}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'depreciation':
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={item.is_depreciation_target ? 'warning' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{item.is_depreciation_target ? '対象' : '対象外'}
|
||||||
|
</Chip>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
const statusChips = []
|
||||||
|
|
||||||
|
if (item.is_disposed) {
|
||||||
|
statusChips.push(<Chip key="disposed" color="danger" size="sm">廃棄済み</Chip>)
|
||||||
|
} else if (item.is_on_loan) {
|
||||||
|
statusChips.push(<Chip key="on_loan" color="warning" size="sm">貸出中</Chip>)
|
||||||
|
} else {
|
||||||
|
statusChips.push(<Chip key="available" color="success" size="sm">利用可能</Chip>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{statusChips}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'purchase_year':
|
||||||
|
return item.purchase_year || '-'
|
||||||
|
|
||||||
|
case 'purchase_amount':
|
||||||
|
return item.purchase_amount ? `¥${item.purchase_amount.toLocaleString()}` : '-'
|
||||||
|
|
||||||
|
case 'created_at':
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{new Date(item.created_at).toLocaleDateString('ja-JP')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'updated_at':
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{new Date(item.updated_at).toLocaleDateString('ja-JP')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'dates':
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
<div>作成: {new Date(item.created_at).toLocaleDateString('ja-JP')}</div>
|
||||||
|
<div>更新: {new Date(item.updated_at).toLocaleDateString('ja-JP')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'actions':
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to={`/items/${item.id}`}
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
title="詳細"
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to={`/items/${item.id}/edit`}
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="primary"
|
||||||
|
title="編集"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</Button>
|
||||||
|
{!item.is_disposed && !item.is_on_loan && (
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to={`/loans/new?item_id=${item.id}`}
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="primary"
|
||||||
|
title="貸出"
|
||||||
|
>
|
||||||
|
<Users size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!item.is_disposed && item.is_on_loan && (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="warning"
|
||||||
|
title="返却"
|
||||||
|
onPress={() => handleReturnItem(item.id)}
|
||||||
|
isLoading={returnItemMutation.isPending}
|
||||||
|
>
|
||||||
|
<Undo size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{item.is_disposed ? (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="success"
|
||||||
|
title="廃棄解除"
|
||||||
|
onPress={() => undisposeItemMutation.mutate(item.id)}
|
||||||
|
isLoading={undisposeItemMutation.isPending}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="danger"
|
||||||
|
title="廃棄"
|
||||||
|
onPress={() => disposeItemMutation.mutate(item.id)}
|
||||||
|
isLoading={disposeItemMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return item[columnKey as keyof Item] || '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">備品一覧</h1>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items/new"
|
||||||
|
color="primary"
|
||||||
|
startContent={<Plus size={20} />}
|
||||||
|
>
|
||||||
|
新規登録
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-danger">
|
||||||
|
エラーが発生しました: {(error as any)?.message || '不明なエラー'}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">備品一覧</h1>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items/new"
|
||||||
|
color="primary"
|
||||||
|
startContent={<Plus size={20} />}
|
||||||
|
>
|
||||||
|
新規登録
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 検索とアクション */}
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
|
<Input
|
||||||
|
isClearable
|
||||||
|
placeholder="備品名、ラベルID、型番で検索..."
|
||||||
|
startContent={<Search size={20} />}
|
||||||
|
value={searchTerm}
|
||||||
|
onClear={() => setSearchTerm('')}
|
||||||
|
onValueChange={setSearchTerm}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="flat"
|
||||||
|
size="sm"
|
||||||
|
onPress={resetFilters}
|
||||||
|
>
|
||||||
|
フィルタリセット
|
||||||
|
</Button>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button
|
||||||
|
variant="flat"
|
||||||
|
startContent={<Settings size={16} />}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
カラム表示
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label="カラム表示設定"
|
||||||
|
closeOnSelect={false}
|
||||||
|
className="max-w-[300px]"
|
||||||
|
>
|
||||||
|
{allColumns.filter(col => col.key !== 'actions').map((column) => (
|
||||||
|
<DropdownItem key={column.key} className="capitalize">
|
||||||
|
<Checkbox
|
||||||
|
isSelected={visibleColumns.has(column.key)}
|
||||||
|
onValueChange={() => toggleColumnVisibility(column.key)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</Checkbox>
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* フィルタ */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<Select
|
||||||
|
label="ステータス"
|
||||||
|
placeholder="すべて"
|
||||||
|
selectedKeys={statusFilter ? [statusFilter] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0] as string
|
||||||
|
setStatusFilter(key as any)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<SelectItem key="all">すべて</SelectItem>
|
||||||
|
<SelectItem key="available">利用可能</SelectItem>
|
||||||
|
<SelectItem key="on_loan">貸出中</SelectItem>
|
||||||
|
<SelectItem key="disposed">廃棄済み</SelectItem>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="QRコード"
|
||||||
|
placeholder="すべて"
|
||||||
|
selectedKeys={qrCodeFilter ? [qrCodeFilter] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0] as string
|
||||||
|
setQrCodeFilter(key as any)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<SelectItem key="all">すべて</SelectItem>
|
||||||
|
<SelectItem key="qr">QRコード</SelectItem>
|
||||||
|
<SelectItem key="barcode">バーコード</SelectItem>
|
||||||
|
<SelectItem key="none">なし</SelectItem>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="減価償却"
|
||||||
|
placeholder="すべて"
|
||||||
|
selectedKeys={depreciationFilter ? [depreciationFilter] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0] as string
|
||||||
|
setDepreciationFilter(key as any)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<SelectItem key="all">すべて</SelectItem>
|
||||||
|
<SelectItem key="target">対象</SelectItem>
|
||||||
|
<SelectItem key="not_target">対象外</SelectItem>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="購入年(開始)"
|
||||||
|
placeholder="2020"
|
||||||
|
type="number"
|
||||||
|
value={purchaseYearFrom}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPurchaseYearFrom(value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="購入年(終了)"
|
||||||
|
placeholder="2024"
|
||||||
|
type="number"
|
||||||
|
value={purchaseYearTo}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPurchaseYearTo(value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* フィルタ結果表示 */}
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{totalFilteredItems}件中 {items.length}件を表示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<Table
|
||||||
|
aria-label="備品一覧"
|
||||||
|
removeWrapper
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={setSortDescriptor}
|
||||||
|
bottomContent={
|
||||||
|
totalPages > 1 && (
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableHeader columns={columns}>
|
||||||
|
{(column) => (
|
||||||
|
<TableColumn
|
||||||
|
key={column.key}
|
||||||
|
align={column.key === 'actions' ? 'center' : 'start'}
|
||||||
|
allowsSorting={column.sortable}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</TableColumn>
|
||||||
|
)}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody
|
||||||
|
items={items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingContent={<Spinner label="読み込み中..." />}
|
||||||
|
emptyContent="備品が登録されていません"
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
{(columnKey) => (
|
||||||
|
<TableCell>
|
||||||
|
{renderCell(item, columnKey)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
276
src/pages/loans/LoanForm.tsx
Normal file
276
src/pages/loans/LoanForm.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { useNavigate, Link, useLocation } from 'react-router-dom'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Snippet,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react'
|
||||||
|
import { useItem, useCreateLoan } from '@/hooks'
|
||||||
|
|
||||||
|
type LoanFormData = {
|
||||||
|
item_id: number
|
||||||
|
student_number: string
|
||||||
|
student_name: string
|
||||||
|
organization?: string
|
||||||
|
loan_date: string
|
||||||
|
remarks?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoanForm() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Get item_id from URL query parameter
|
||||||
|
const searchParams = new URLSearchParams(location.search)
|
||||||
|
const itemId = searchParams.get('item_id')
|
||||||
|
|
||||||
|
// Fetch specific item data if item_id is provided
|
||||||
|
const { data: selectedItem } = useItem(itemId ? Number(itemId) : 0)
|
||||||
|
|
||||||
|
// Create loan mutation
|
||||||
|
const createLoanMutation = useCreateLoan()
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoanFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
item_id: itemId ? Number(itemId) : 0,
|
||||||
|
student_number: '',
|
||||||
|
student_name: '',
|
||||||
|
organization: '',
|
||||||
|
loan_date: new Date().toISOString().split('T')[0],
|
||||||
|
remarks: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update form when item_id is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemId) {
|
||||||
|
setValue('item_id', Number(itemId))
|
||||||
|
}
|
||||||
|
}, [itemId, setValue])
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoanFormData) => {
|
||||||
|
try {
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!data.item_id) {
|
||||||
|
setSubmitError('備品を選択してください。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.student_number?.trim()) {
|
||||||
|
setSubmitError('学籍番号は必須です。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.student_name?.trim()) {
|
||||||
|
setSubmitError('氏名は必須です。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.loan_date) {
|
||||||
|
setSubmitError('貸出日は必須です。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and prepare data for API
|
||||||
|
const loanData = {
|
||||||
|
item_id: Number(data.item_id),
|
||||||
|
student_number: data.student_number.trim(),
|
||||||
|
student_name: data.student_name.trim(),
|
||||||
|
organization: data.organization?.trim() || undefined,
|
||||||
|
loan_date: data.loan_date,
|
||||||
|
remarks: data.remarks?.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
await createLoanMutation.mutateAsync(loanData)
|
||||||
|
navigate('/items')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Loan creation error:', error)
|
||||||
|
|
||||||
|
let errorMessage = '貸出登録に失敗しました。'
|
||||||
|
|
||||||
|
if (error?.response?.status === 400) {
|
||||||
|
errorMessage = 'リクエストデータに問題があります。入力内容を確認してください。'
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
errorMessage += ` (${error.response.data.message})`
|
||||||
|
}
|
||||||
|
} else if (error?.response?.status === 404) {
|
||||||
|
errorMessage = '指定された備品が見つかりません。'
|
||||||
|
} else if (error?.response?.status === 409) {
|
||||||
|
errorMessage = 'この備品は既に貸出中です。'
|
||||||
|
} else if (error?.response?.status === 500) {
|
||||||
|
errorMessage = 'サーバーエラーが発生しました。管理者に連絡してください。'
|
||||||
|
} else if (error?.message?.includes('Network Error')) {
|
||||||
|
errorMessage = 'ネットワーク接続を確認してください。'
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitError(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="light"
|
||||||
|
startContent={<ArrowLeft size={20} />}
|
||||||
|
>
|
||||||
|
備品一覧に戻る
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold">新規貸出登録</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-xl font-semibold">貸出情報</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
貸出備品 <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{selectedItem ? (
|
||||||
|
<Card>
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{selectedItem.image_url && (
|
||||||
|
<img
|
||||||
|
src={selectedItem.image_url}
|
||||||
|
alt={selectedItem.name}
|
||||||
|
className="w-16 h-16 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold">{selectedItem.name}</h3>
|
||||||
|
<p className="text-sm text-gray-600">ラベルID: {selectedItem.label_id}</p>
|
||||||
|
{selectedItem.model_number && (
|
||||||
|
<p className="text-sm text-gray-600">型番: {selectedItem.model_number}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">
|
||||||
|
利用可能
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 p-4 border rounded-lg">
|
||||||
|
備品情報を読み込み中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input type="hidden" {...register('item_id', { required: '備品を選択してください' })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('loan_date', { required: '貸出日は必須です' })}
|
||||||
|
type="date"
|
||||||
|
label="貸出日"
|
||||||
|
errorMessage={errors.loan_date?.message}
|
||||||
|
isInvalid={!!errors.loan_date}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('student_number', { required: '学籍番号は必須です' })}
|
||||||
|
label="学籍番号"
|
||||||
|
placeholder="例: 2023001"
|
||||||
|
errorMessage={errors.student_number?.message}
|
||||||
|
isInvalid={!!errors.student_number}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('student_name', { required: '氏名は必須です' })}
|
||||||
|
label="氏名"
|
||||||
|
placeholder="例: 山田太郎"
|
||||||
|
errorMessage={errors.student_name?.message}
|
||||||
|
isInvalid={!!errors.student_name}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('organization')}
|
||||||
|
label="所属"
|
||||||
|
placeholder="例: 情報工学科"
|
||||||
|
className="md:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Textarea
|
||||||
|
{...register('remarks')}
|
||||||
|
label="備考"
|
||||||
|
placeholder="貸出に関する追加情報を入力してください"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-xl font-semibold">注意事項</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-sm text-gray-600">
|
||||||
|
<li>備品の貸出期限は原則として2週間です</li>
|
||||||
|
<li>期限を過ぎる場合は必ず連絡してください</li>
|
||||||
|
<li>破損・紛失した場合は速やかに報告してください</li>
|
||||||
|
<li>返却時は借りた時と同じ状態で返却してください</li>
|
||||||
|
</ul>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardBody>
|
||||||
|
<Snippet color="danger" variant="flat" symbol="⚠️">
|
||||||
|
{submitError}
|
||||||
|
</Snippet>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4 mt-6">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
variant="flat"
|
||||||
|
isDisabled={createLoanMutation.isPending}
|
||||||
|
>
|
||||||
|
キャンセル
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
startContent={<Save size={16} />}
|
||||||
|
isLoading={createLoanMutation.isPending}
|
||||||
|
>
|
||||||
|
登録
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
297
src/pages/loans/LoansList.tsx
Normal file
297
src/pages/loans/LoansList.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableColumn,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Chip,
|
||||||
|
Pagination,
|
||||||
|
Spinner,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Dropdown,
|
||||||
|
DropdownTrigger,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
SortDescriptor,
|
||||||
|
} from '@heroui/react'
|
||||||
|
import { Search, Plus, Filter, RotateCcw } from 'lucide-react'
|
||||||
|
import { Loan } from '@/types'
|
||||||
|
import { useLoans, useReturnItem } from '@/hooks'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { ja } from 'date-fns/locale'
|
||||||
|
|
||||||
|
export function LoansList() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'returned'>('all')
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||||
|
column: 'loan_date',
|
||||||
|
direction: 'descending',
|
||||||
|
})
|
||||||
|
|
||||||
|
const returnItemMutation = useReturnItem()
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useLoans({
|
||||||
|
page,
|
||||||
|
per_page: 20,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: filterStatus === 'all' ? undefined : filterStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
// APIフィルタリングが効かない場合に備えて、クライアント側でも追加フィルタリング
|
||||||
|
const allLoans = data?.data || []
|
||||||
|
const filteredLoans = filterStatus === 'all'
|
||||||
|
? allLoans
|
||||||
|
: filterStatus === 'active'
|
||||||
|
? allLoans.filter(loan => !loan.return_date)
|
||||||
|
: allLoans.filter(loan => !!loan.return_date)
|
||||||
|
|
||||||
|
// ソート処理
|
||||||
|
const sortedLoans = [...filteredLoans].sort((a, b) => {
|
||||||
|
const { column, direction } = sortDescriptor
|
||||||
|
let aValue: any = a[column as keyof Loan]
|
||||||
|
let bValue: any = b[column as keyof Loan]
|
||||||
|
|
||||||
|
// アイテム名でソートする場合
|
||||||
|
if (column === 'item_name') {
|
||||||
|
aValue = a.item?.name || ''
|
||||||
|
bValue = b.item?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日付の場合
|
||||||
|
if (column === 'loan_date' || column === 'return_date' || column === 'created_at' || column === 'updated_at') {
|
||||||
|
if (!aValue && !bValue) return 0
|
||||||
|
if (!aValue) return direction === 'ascending' ? -1 : 1
|
||||||
|
if (!bValue) return direction === 'ascending' ? 1 : -1
|
||||||
|
aValue = new Date(aValue).getTime()
|
||||||
|
bValue = new Date(bValue).getTime()
|
||||||
|
return direction === 'ascending' ? aValue - bValue : bValue - aValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字列の場合
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
aValue = aValue.toLowerCase()
|
||||||
|
bValue = bValue.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数値の場合
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
return direction === 'ascending' ? aValue - bValue : bValue - aValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字列比較
|
||||||
|
if (aValue < bValue) return direction === 'ascending' ? -1 : 1
|
||||||
|
if (aValue > bValue) return direction === 'ascending' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const loans = sortedLoans
|
||||||
|
const totalPages = Math.ceil(loans.length / 20) || 1
|
||||||
|
|
||||||
|
const handleReturnItem = async (loanId: number) => {
|
||||||
|
try {
|
||||||
|
await returnItemMutation.mutateAsync({
|
||||||
|
id: loanId,
|
||||||
|
data: {
|
||||||
|
remarks: '貸出管理画面から返却'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Return item error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'loan_date', label: '貸出日', sortable: true },
|
||||||
|
{ key: 'item_name', label: '備品名', sortable: true },
|
||||||
|
{ key: 'student_name', label: '借用者', sortable: true },
|
||||||
|
{ key: 'organization', label: '所属', sortable: true },
|
||||||
|
{ key: 'return_date', label: '返却日', sortable: true },
|
||||||
|
{ key: 'status', label: 'ステータス', sortable: false },
|
||||||
|
{ key: 'actions', label: '操作', sortable: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderCell = (loan: Loan, columnKey: React.Key) => {
|
||||||
|
switch (columnKey) {
|
||||||
|
case 'loan_date':
|
||||||
|
return format(new Date(loan.loan_date), 'yyyy/MM/dd', { locale: ja })
|
||||||
|
case 'item_name':
|
||||||
|
return loan.item?.name || `備品ID: ${loan.item_id}`
|
||||||
|
case 'student':
|
||||||
|
case 'student_name':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{loan.student_name}</p>
|
||||||
|
<p className="text-sm text-gray-600">{loan.student_number}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'organization':
|
||||||
|
return loan.organization || '-'
|
||||||
|
case 'return_date':
|
||||||
|
return loan.return_date ? format(new Date(loan.return_date), 'yyyy/MM/dd', { locale: ja }) : '-'
|
||||||
|
case 'status':
|
||||||
|
return loan.return_date ? (
|
||||||
|
<Chip color="success" size="sm">返却済み</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip color="warning" size="sm">貸出中</Chip>
|
||||||
|
)
|
||||||
|
case 'actions':
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!loan.return_date && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
startContent={<RotateCcw size={16} />}
|
||||||
|
onPress={() => handleReturnItem(loan.id)}
|
||||||
|
isLoading={returnItemMutation.isPending}
|
||||||
|
>
|
||||||
|
返却
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">貸出管理</h1>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
color="primary"
|
||||||
|
startContent={<Plus size={20} />}
|
||||||
|
>
|
||||||
|
備品から貸出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p className="text-center text-danger">
|
||||||
|
エラーが発生しました: {(error as any)?.message || '不明なエラー'}
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">貸出管理</h1>
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
to="/items"
|
||||||
|
color="primary"
|
||||||
|
startContent={<Plus size={20} />}
|
||||||
|
>
|
||||||
|
備品から貸出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
isClearable
|
||||||
|
placeholder="備品名、借用者名、学籍番号で検索..."
|
||||||
|
startContent={<Search size={20} />}
|
||||||
|
value={searchTerm}
|
||||||
|
onClear={() => setSearchTerm('')}
|
||||||
|
onValueChange={setSearchTerm}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button
|
||||||
|
variant="flat"
|
||||||
|
startContent={<Filter size={16} />}
|
||||||
|
>
|
||||||
|
{filterStatus === 'all' ? 'すべて' : filterStatus === 'active' ? '貸出中' : '返却済み'}
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label="フィルター"
|
||||||
|
selectedKeys={new Set([filterStatus])}
|
||||||
|
selectionMode="single"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selectedKey = Array.from(keys)[0] as 'all' | 'active' | 'returned'
|
||||||
|
setFilterStatus(selectedKey)
|
||||||
|
setPage(1) // フィルタ変更時にページをリセット
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownItem key="all">すべて</DropdownItem>
|
||||||
|
<DropdownItem key="active">貸出中</DropdownItem>
|
||||||
|
<DropdownItem key="returned">返却済み</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<Table
|
||||||
|
aria-label="貸出管理"
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={setSortDescriptor}
|
||||||
|
bottomContent={
|
||||||
|
totalPages > 1 && (
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
showControls
|
||||||
|
showShadow
|
||||||
|
color="primary"
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableHeader columns={columns}>
|
||||||
|
{(column) => (
|
||||||
|
<TableColumn
|
||||||
|
key={column.key}
|
||||||
|
align={column.key === 'actions' ? 'center' : 'start'}
|
||||||
|
allowsSorting={column.sortable}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</TableColumn>
|
||||||
|
)}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody
|
||||||
|
items={loans}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingContent={<Spinner label="読み込み中..." />}
|
||||||
|
emptyContent="貸出記録がありません"
|
||||||
|
>
|
||||||
|
{(loan) => (
|
||||||
|
<TableRow key={loan.id}>
|
||||||
|
{(columnKey) => (
|
||||||
|
<TableCell>{renderCell(loan, columnKey)}</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/routes.tsx
Normal file
30
src/routes.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import { Layout } from '@/components/layout/Layout'
|
||||||
|
import { Dashboard } from '@/pages/dashboard/Dashboard'
|
||||||
|
import { ItemsList } from '@/pages/items/ItemsList'
|
||||||
|
import { ItemDetail } from '@/pages/items/ItemDetail'
|
||||||
|
import { ItemForm } from '@/pages/items/ItemForm'
|
||||||
|
import { LoanForm } from '@/pages/loans/LoanForm'
|
||||||
|
import { LoansList } from '@/pages/loans/LoansList'
|
||||||
|
import { CableColorsList } from '@/pages/cable-colors/CableColorsList'
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="items">
|
||||||
|
<Route index element={<ItemsList />} />
|
||||||
|
<Route path="new" element={<ItemForm />} />
|
||||||
|
<Route path=":id" element={<ItemDetail />} />
|
||||||
|
<Route path=":id/edit" element={<ItemForm />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="loans">
|
||||||
|
<Route index element={<LoansList />} />
|
||||||
|
<Route path="new" element={<LoanForm />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="cable-colors" element={<CableColorsList />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/services/api.ts
Normal file
67
src/services/api.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import axios, { AxiosError } from 'axios'
|
||||||
|
import { ApiError } from '@/types'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080/api/v1'
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// Log API requests in development
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log(`API Request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add auth token when implemented
|
||||||
|
// const token = localStorage.getItem('token')
|
||||||
|
// if (token) {
|
||||||
|
// config.headers.Authorization = `Bearer ${token}`
|
||||||
|
// }
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('API Request Error:', error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
// Log API responses in development
|
||||||
|
if (import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
console.log(`API Response: ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
console.error('API Error:', error)
|
||||||
|
|
||||||
|
const apiError: ApiError = {
|
||||||
|
message: 'エラーが発生しました',
|
||||||
|
status: error.response?.status || 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network errors
|
||||||
|
if (error.code === 'ERR_NETWORK') {
|
||||||
|
apiError.message = 'バックエンドサーバーに接続できません。サーバーが起動しているか確認してください。'
|
||||||
|
apiError.status = 0
|
||||||
|
} else if (error.response?.data && typeof error.response.data === 'object') {
|
||||||
|
const data = error.response.data as any
|
||||||
|
if (data.message) {
|
||||||
|
apiError.message = data.message
|
||||||
|
}
|
||||||
|
if (data.errors) {
|
||||||
|
apiError.errors = data.errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(apiError)
|
||||||
|
}
|
||||||
|
)
|
||||||
39
src/services/cableColors.ts
Normal file
39
src/services/cableColors.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { api } from './api'
|
||||||
|
import { CableColor, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const cableColorsService = {
|
||||||
|
async getAll(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
}): Promise<PaginatedResponse<CableColor>> {
|
||||||
|
const response = await api.get('/cable_colors', { params })
|
||||||
|
const data = response.data
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.cable_colors || data.data || [],
|
||||||
|
total: data.total || 0,
|
||||||
|
page: data.page || 1,
|
||||||
|
per_page: data.per_page || 20,
|
||||||
|
total_pages: Math.ceil((data.total || 0) / (data.per_page || 20)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: number): Promise<CableColor> {
|
||||||
|
const response = await api.get(`/cable_colors/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: { name: string; hex_code: string }): Promise<CableColor> {
|
||||||
|
const response = await api.post('/cable_colors', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, data: { name?: string; hex_code?: string }): Promise<CableColor> {
|
||||||
|
const response = await api.put(`/cable_colors/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: number): Promise<void> {
|
||||||
|
await api.delete(`/cable_colors/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
21
src/services/health.ts
Normal file
21
src/services/health.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export const healthService = {
|
||||||
|
async check(): Promise<{ status: string; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/health')
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.check()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
19
src/services/images.ts
Normal file
19
src/services/images.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export const imagesService = {
|
||||||
|
async upload(file: File): Promise<{ url: string; filename: string }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await api.post('/images/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(filename: string): Promise<void> {
|
||||||
|
await api.delete(`/images/${filename}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
5
src/services/index.ts
Normal file
5
src/services/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './api'
|
||||||
|
export * from './items'
|
||||||
|
export * from './loans'
|
||||||
|
export * from './images'
|
||||||
|
export * from './health'
|
||||||
89
src/services/items.ts
Normal file
89
src/services/items.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { api } from './api'
|
||||||
|
import { Item, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const itemsService = {
|
||||||
|
async getAll(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
status?: 'available' | 'on_loan' | 'disposed'
|
||||||
|
}): Promise<PaginatedResponse<Item>> {
|
||||||
|
const response = await api.get('/items', { params })
|
||||||
|
const data = response.data
|
||||||
|
|
||||||
|
// Transform API response to match our PaginatedResponse interface
|
||||||
|
return {
|
||||||
|
data: data.items || [],
|
||||||
|
total: data.total || 0,
|
||||||
|
page: data.page || 1,
|
||||||
|
per_page: data.per_page || 20,
|
||||||
|
total_pages: Math.ceil((data.total || 0) / (data.per_page || 20)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: number): Promise<Item> {
|
||||||
|
const response = await api.get(`/items/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Omit<Item, 'id' | 'created_at' | 'updated_at'>): Promise<Item> {
|
||||||
|
const response = await api.post('/items', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, data: Partial<Omit<Item, 'id' | 'created_at' | 'updated_at'>>): Promise<Item> {
|
||||||
|
const response = await api.put(`/items/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: number): Promise<void> {
|
||||||
|
await api.delete(`/items/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadImage(id: number, file: File): Promise<{ image_url: string }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', file)
|
||||||
|
|
||||||
|
const response = await api.post(`/items/${id}/image`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async dispose(id: number): Promise<Item> {
|
||||||
|
const response = await api.post(`/items/${id}/dispose`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async undispose(id: number): Promise<Item> {
|
||||||
|
const response = await api.post(`/items/${id}/undispose`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSuggestions(field: 'connection_names' | 'cable_color_pattern' | 'storage_locations'): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Try to get suggestions from a dedicated endpoint if available
|
||||||
|
const response = await api.get(`/items/suggestions/${field}`)
|
||||||
|
return response.data.suggestions || []
|
||||||
|
} catch (error) {
|
||||||
|
// If endpoint doesn't exist, extract from all items
|
||||||
|
const allItems = await this.getAll({ per_page: 1000 })
|
||||||
|
const suggestions = new Set<string>()
|
||||||
|
|
||||||
|
allItems.data.forEach(item => {
|
||||||
|
const fieldValue = item[field]
|
||||||
|
if (Array.isArray(fieldValue)) {
|
||||||
|
fieldValue.forEach(value => {
|
||||||
|
if (value && typeof value === 'string') {
|
||||||
|
suggestions.add(value.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(suggestions).sort()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
55
src/services/loans.ts
Normal file
55
src/services/loans.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { api } from './api'
|
||||||
|
import { Loan, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const loansService = {
|
||||||
|
async getAll(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
search?: string
|
||||||
|
status?: 'active' | 'returned'
|
||||||
|
item_id?: number
|
||||||
|
student_number?: string
|
||||||
|
}): Promise<PaginatedResponse<Loan>> {
|
||||||
|
const response = await api.get('/loans', { params })
|
||||||
|
const data = response.data
|
||||||
|
|
||||||
|
// Transform API response to match our PaginatedResponse interface
|
||||||
|
return {
|
||||||
|
data: data.loans || data.items || data || [],
|
||||||
|
total: data.total || 0,
|
||||||
|
page: data.page || 1,
|
||||||
|
per_page: data.per_page || 20,
|
||||||
|
total_pages: Math.ceil((data.total || 0) / (data.per_page || 20)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: number): Promise<Loan> {
|
||||||
|
const response = await api.get(`/loans/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: Omit<Loan, 'id' | 'created_at' | 'updated_at' | 'item'>): Promise<Loan> {
|
||||||
|
const response = await api.post('/loans', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async returnItem(id: number, data: { remarks?: string }): Promise<Loan> {
|
||||||
|
const response = await api.post(`/loans/${id}/return`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveByItemId(itemId: number): Promise<Loan | null> {
|
||||||
|
const response = await api.get(`/items/${itemId}/active-loan`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHistory(params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
item_id?: number
|
||||||
|
student_number?: string
|
||||||
|
}): Promise<PaginatedResponse<Loan>> {
|
||||||
|
const response = await api.get('/loans/history', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
3
src/styles/globals.css
Normal file
3
src/styles/globals.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
62
src/types/index.ts
Normal file
62
src/types/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
export interface Item {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
label_id: string
|
||||||
|
model_number?: string
|
||||||
|
remarks?: string
|
||||||
|
purchase_year?: number
|
||||||
|
purchase_amount?: number
|
||||||
|
durability_years?: number
|
||||||
|
is_depreciation_target?: boolean
|
||||||
|
connection_names?: string[]
|
||||||
|
cable_color_pattern?: string[]
|
||||||
|
storage_locations?: string[]
|
||||||
|
is_on_loan?: boolean
|
||||||
|
qr_code_type?: 'qr' | 'barcode' | 'none'
|
||||||
|
is_disposed?: boolean
|
||||||
|
image_url?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Loan {
|
||||||
|
id: number
|
||||||
|
item_id: number
|
||||||
|
student_number: string
|
||||||
|
student_name: string
|
||||||
|
organization?: string
|
||||||
|
loan_date: string
|
||||||
|
return_date?: string
|
||||||
|
remarks?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
item?: Item
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T
|
||||||
|
status: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CableColor {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
hex_code: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
status: number
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
43
src/utils/index.ts
Normal file
43
src/utils/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('ja-JP', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'JPY',
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return new Intl.DateTimeFormat('ja-JP', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string | Date): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return new Intl.DateTimeFormat('ja-JP', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFileSize(file: File, maxSizeInMB: number = 10): boolean {
|
||||||
|
const maxSizeInBytes = maxSizeInMB * 1024 * 1024
|
||||||
|
return file.size <= maxSizeInBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateImageType(file: File): boolean {
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
return allowedTypes.includes(file.type)
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
15
tailwind.config.cjs
Normal file
15
tailwind.config.cjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const { nextui } = require("@nextui-org/react");
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
darkMode: "class",
|
||||||
|
plugins: [nextui()],
|
||||||
|
};
|
||||||
15
tailwind.config.js
Normal file
15
tailwind.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const { heroui } = require("@heroui/react");
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
darkMode: "class",
|
||||||
|
plugins: [heroui()],
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
20
tsconfig.node.json
Normal file
20
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
14
vite.config.js
Normal file
14
vite.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue