This commit is contained in:
Soma Nakamura 2025-07-05 11:52:57 +09:00
commit 4a6decc70e
55 changed files with 18446 additions and 0 deletions

5
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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設定を確認
- 開発環境では通常自動設定される

1143
bun.lock Normal file

File diff suppressed because it is too large Load diff

61
flake.lock generated Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

44
package.json Normal file
View 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
View file

@ -0,0 +1,8 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {
base: './src/index.css',
config: './tailwind.config.cjs'
},
},
};

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

24
src/App.tsx Normal file
View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View file

@ -0,0 +1,3 @@
export * from './useItems'
export * from './useLoans'
export * from './useCableColors'

View 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
View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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)
}
)

View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

62
src/types/index.ts Normal file
View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

15
tailwind.config.cjs Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

14
vite.config.js Normal file
View 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
View 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,
},
})