0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

弱々エンジニアの生成AIを用いた開発 #5-6 (フロントエンド開発 工事一覧改修~経費入力)

Posted at

前回のおさらい

前回やったこと

  • 工事一覧画面作成
  • 工事追加画面作成
  • 工事詳細画面作成

今回やること

  • 工事一覧画面作成(エラー解消と同時に)
  • 経費入力画面作成

遷移図の方
image.png

前回の続きですが
工事追加画面を作成した際に、色々と弄ったらHome画面から工事画面一覧に行く際
エラーとなってしまう状態になっていました

エラー解消しても良いのですが、表示内容、データの取り扱い方的に
一部修正したい箇所があったため、そこも含めて修正してもらうようにしたい
と考えています

一応今回のプロンプト

工事一覧に表示させる内容を修正します
出力内容は下記の通り
・工事名
・部署
・受注金額(税込)
・実行予算
・使用済み経費(コスト)
・発注者
・工事場所
・工期開始
・工期終了
・会計基準(進行/完成)

この中で、工事情報追加時に入力させるのは
・工事名(※)
・部署(※)
・受注金額(※)
・実行予算
・発注者
・工事場所
・工期開始
・工期終了
・会計基準(ラジオボタンで進行/完成)
(※)は必須入力
以上で
・使用済み経費(コスト)
については、工事詳細の方で経費入力を行うページを作成

入力させるのは下記
・発生日
・金額
・経費概要(外注費、労務費、材料費、その他)
使用済み経費については、金額を合算し、工事一覧に表示させるようにする
工事一覧を表示する際、使用済みコストが無い場合は0で表示する

ちょっと長くなりましたが、これでAIに投げてみたいと思います

生成AI(Grok3)での出力となります(今回も修正点抽出)

フォルダ構成

フォルダ構成
C:\projects\cbit
├── src
│   ├── components
│   │   └── auth
│   │       ├── Login.js (Unchanged)
│   │       └── Signup.js (Unchanged)
│   ├── pages
│   │   ├── Home.js (Unchanged)
│   │   ├── ProjectList.js (Modified)        // 表示内容修正、使用済み経費合算表示、使用済み経費0表示対応
│   │   ├── ProjectDetail.js (Modified)      // 使用済み経費表示、経費入力ページ遷移追加
│   │   ├── ProjectAdd.js (Modified)         // 入力項目修正、必須項目検証
│   │   └── ProjectExpenseAdd.js (New)       // 経費入力ページ追加
│   ├── utils
│   │   ├── firebase.js (Unchanged)
│   │   └── data.js (Modified)               // 経費追加関数、合算関数追加、getTotalExpensesで0明示
│   ├── App.js (Modified)                    // /projects/:id/expense-add ルート追加 
├── .env
├── package.json

App.js(Modify)

変更点:/projects/:id/expense-add ルートを追加、ProjectExpenseAdd をインポート。

App.js
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { auth } from './utils/firebase';
import { useAuthState } from 'react-firebase-hooks/auth';
import Home from './pages/Home';
import Login from './components/auth/Login';
import Signup from './components/auth/Signup';
import ProjectList from './pages/ProjectList';
import ProjectDetail from './pages/ProjectDetail';
import ProjectAdd from './pages/ProjectAdd';
import ProjectExpenseAdd from './pages/ProjectExpenseAdd'; // Added

const App = () => {
  const [user, loading] = useAuthState(auth);

  if (loading) return <div>読み込み中...</div>;

  return (
    <Router>
      <Routes>
        <Route path="/login" element={user ? <Home /> : <Login />} />
        <Route path="/signup" element={user ? <Home /> : <Signup />} />
        <Route path="/" element={user ? <Home /> : <Login />} />
        <Route path="/projects" element={user ? <ProjectList /> : <Login />} />
        <Route path="/projects/:id" element={user ? <ProjectDetail /> : <Login />} />
        <Route path="/projects/add" element={user ? <ProjectAdd /> : <Login />} />
        <Route path="/projects/:id/expense-add" element={user ? <ProjectExpenseAdd /> : <Login />} /> {/* Added */}
      </Routes>
    </Router>
  );
};

export default App;

data.js(modify)

変更点:経費追加関数 addExpense を追加、使用済み経費合算関数 getTotalExpenses を追加。Firestoreのprojects/{id}/expenses サブコレクションを作成。getTotalExpenses 関数で経費データが空の場合に明示的に0を返すように修正。

data.js
import { collection, getDocs, addDoc, getDoc, doc } from 'firebase/firestore';
import { db } from './firebase';

export const fetchProjectData = async () => {
  try {
    const querySnapshot = await getDocs(collection(db, 'projects'));
    return querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
  } catch (error) {
    console.error('データ取得エラー:', error);
    return [];
  }
};

export const fetchProjectById = async (id) => {
  try {
    const docRef = doc(db, 'projects', id);
    const docSnap = await getDoc(docRef);
    if (docSnap.exists()) {
      return { id: docSnap.id, ...docSnap.data() };
    } else {
      throw new Error('工事が見つかりません');
    }
  } catch (error) {
    console.error('工事詳細取得エラー:', error);
    return null;
  }
};

export const addProject = async (projectData) => {
  try {
    await addDoc(collection(db, 'projects'), {
      name: projectData.name,
      department: projectData.department,
      contractAmountExTax: projectData.contractAmountExTax || 0,
      contractAmountIncTax: projectData.contractAmountIncTax || 0,
      client: projectData.client || '',
      location: projectData.location || '',
      startDate: projectData.startDate,
      endDate: projectData.endDate,
      accountingStandard: projectData.accountingStandard,
    });
    console.log('工事データを追加しました');
  } catch (error) {
    console.error('データ追加エラー:', error);
  }
};

export const addExpense = async (projectId, expenseData) => {
  try {
    const expenseRef = collection(db, 'projects', projectId, 'expenses');
    await addDoc(expenseRef, {
      occurrenceDate: expenseData.occurrenceDate,
      amount: expenseData.amount || 0,
      description: expenseData.description,
    });
    console.log('経費を追加しました');
  } catch (error) {
    console.error('経費追加エラー:', error);
  }
};

export const getTotalExpenses = async (projectId) => {
  try {
    const expenseRef = collection(db, 'projects', projectId, 'expenses');
    const querySnapshot = await getDocs(expenseRef);
    if (querySnapshot.empty) return 0; // 経費データが空の場合0を返す
    return querySnapshot.docs.reduce((sum, doc) => sum + (doc.data().amount || 0), 0);
  } catch (error) {
    console.error('経費合算エラー:', error);
    return 0; // エラー時も0を返す
  }
};

ProjectList.js(Modify)

変更点:使用済み経費(コスト)合算表示追加(getTotalExpenses で取得)、表示項目を工事名、部署、受注金額(税込)、実行予算、使用済み経費(コスト)、発注者、工事場所、工期開始、工期終了、会計基準に修正。フィルター機能は工事名、部署、発注者に維持。使用済み経費(totalExpenses)が未定義または空の場合に0を表示するよう、表示ロジックを調整。

ProjectList.js
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, TextField } from '@mui/material';
import { fetchProjectData, getTotalExpenses } from '../utils/data';

const ProjectList = () => {
  const [projects, setProjects] = useState([]);
  const [filteredProjects, setFilteredProjects] = useState([]);
  const [filters, setFilters] = useState({ name: '', department: '', client: '' });
  const navigate = useNavigate();

  useEffect(() => {
    const loadData = async () => {
      const data = await fetchProjectData();
      const projectsWithExpenses = await Promise.all(data.map(async (project) => {
        const totalExpenses = await getTotalExpenses(project.id);
        return { ...project, totalExpenses: totalExpenses || 0 }; // 明示的に0を設定
      }));
      setProjects(projectsWithExpenses);
      setFilteredProjects(projectsWithExpenses);
    };
    loadData();
  }, []);

  useEffect(() => {
    const filtered = projects.filter(project =>
      project.name.toLowerCase().includes(filters.name.toLowerCase()) &&
      project.department.toLowerCase().includes(filters.department.toLowerCase()) &&
      project.client.toLowerCase().includes(filters.client.toLowerCase())
    );
    setFilteredProjects(filtered);
  }, [filters, projects]);

  const handleFilterChange = (e) => {
    const { name, value } = e.target;
    setFilters(prev => ({ ...prev, [name]: value }));
  };

  return (
    <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2 }}>
      <Typography variant="h4" gutterBottom>工事一覧</Typography>
      <Button variant="outlined" onClick={() => navigate('/projects/add')} sx={{ mb: 2 }}>
        工事追加
      </Button>
      <Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
        <TextField
          label="工事名で検索"
          name="name"
          value={filters.name}
          onChange={handleFilterChange}
          size="small"
        />
        <TextField
          label="部署で検索"
          name="department"
          value={filters.department}
          onChange={handleFilterChange}
          size="small"
        />
        <TextField
          label="発注者で検索"
          name="client"
          value={filters.client}
          onChange={handleFilterChange}
          size="small"
        />
      </Box>
      <TableContainer component={Paper}>
        <Table>
          <TableHead>
            <TableRow>
              <TableCell>工事名</TableCell>
              <TableCell align="right">部署</TableCell>
              <TableCell align="right">受注金額税込 (万円)</TableCell>
              <TableCell align="right">実行予算 (万円)</TableCell>
              <TableCell align="right">使用済み経費(コスト) (万円)</TableCell>
              <TableCell align="right">発注者</TableCell>
              <TableCell align="right">工事場所</TableCell>
              <TableCell align="right">工期開始</TableCell>
              <TableCell align="right">工期終了</TableCell>
              <TableCell align="right">会計基準</TableCell>
              <TableCell align="right">詳細</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {filteredProjects.map(project => (
              <TableRow key={project.id}>
                <TableCell>{project.name}</TableCell>
                <TableCell align="right">{project.department}</TableCell>
                <TableCell align="right">{(project.contractAmountIncTax / 10000).toFixed(1)}</TableCell>
                <TableCell align="right">{(project.budget / 10000).toFixed(1)}</TableCell>
                <TableCell align="right">{((project.totalExpenses || 0) / 10000).toFixed(1)}</TableCell>
                <TableCell align="right">{project.client}</TableCell>
                <TableCell align="right">{project.location}</TableCell>
                <TableCell align="right">{project.startDate ? new Date(project.startDate).toLocaleDateString('ja-JP') : '-'}</TableCell>
                <TableCell align="right">{project.endDate ? new Date(project.endDate).toLocaleDateString('ja-JP') : '-'}</TableCell>
                <TableCell align="right">{project.accountingStandard}</TableCell>
                <TableCell align="right">
                  <Button variant="outlined" onClick={() => navigate(`/projects/${project.id}`)}>
                    詳細
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
    </Box>
  );
};

export default ProjectList;

ProjectDetail.js(Modify)

変更点:使用済み経費合算表示(getTotalExpenses で取得)、経費入力ページ(/projects/:id/expense-add)へのボタンを追加、表示項目を新しいデータ構造に更新。

ProjectDetail.js
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Paper, Button, Grid } from '@mui/material';
import { fetchProjectById, getTotalExpenses } from '../utils/data';

const ProjectDetail = () => {
  const { id } = useParams();
  const [project, setProject] = useState(null);
  const [totalExpenses, setTotalExpenses] = useState(0);
  const navigate = useNavigate();

  useEffect(() => {
    const loadProject = async () => {
      const data = await fetchProjectById(id);
      setProject(data);
      const expenses = await getTotalExpenses(id);
      setTotalExpenses(expenses);
    };
    loadProject();
  }, [id]);

  if (!project) return <Typography>読み込み中...</Typography>;

  return (
    <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2 }}>
      <Typography variant="h4" gutterBottom>{project.name} の詳細</Typography>
      <Button variant="outlined" onClick={() => navigate('/projects')} sx={{ mb: 2, mr: 2 }}>
        工事一覧に戻る
      </Button>
      <Button variant="outlined" onClick={() => navigate(`/projects/${id}/expense-add`)} sx={{ mb: 2 }}>
        経費入力
      </Button>
      <Paper sx={{ p: 2 }}>
        <Grid container spacing={2}>
          <Grid item xs={12} sm={6}>
            <Typography variant="h6">基本情報</Typography>
            <Typography>工事名: {project.name}</Typography>
            <Typography>部署: {project.department}</Typography>
            <Typography>受注金額税抜: {(project.contractAmountExTax / 10000).toFixed(1)}万円</Typography>
            <Typography>受注金額税込: {(project.contractAmountIncTax / 10000).toFixed(1)}万円</Typography>
            <Typography>発注者: {project.client}</Typography>
            <Typography>工事場所: {project.location}</Typography>
            <Typography>工期開始: {project.startDate ? new Date(project.startDate).toLocaleDateString('ja-JP') : '-'}</Typography>
            <Typography>工期終了: {project.endDate ? new Date(project.endDate).toLocaleDateString('ja-JP') : '-'}</Typography>
            <Typography>会計基準: {project.accountingStandard}</Typography>
          </Grid>
          <Grid item xs={12} sm={6}>
            <Typography variant="h6">財務情報</Typography>
            <Typography>実行予算: {(project.budget / 10000).toFixed(1)}万円</Typography>
            <Typography>使用済み経費: {(totalExpenses / 10000).toFixed(1)}万円</Typography>
          </Grid>
        </Grid>
      </Paper>
    </Box>
  );
};

export default ProjectDetail;

ProjectExpenseAdd.js(New)

変更点:新規作成。経費入力ページで発生日、金額、経費概要(外注費、労務費、材料費、その他)を入力、Firestoreのサブコレクションprojects/{id}/expensesに保存、使用済み経費を合算。

ProjectExpenseAdd.js
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Paper, Button, TextField, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { addExpense } from '../utils/data';

const ProjectExpenseAdd = () => {
  const { id } = useParams();
  const [formData, setFormData] = useState({
    occurrenceDate: null,
    amount: '',
    description: '',
  });
  const [error, setError] = useState('');
  const navigate = useNavigate();

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async () => {
    if (!formData.occurrenceDate || !formData.amount || !formData.description) {
      setError('必須項目(発生日、金額、経費概要)を入力してください');
      return;
    }
    try {
      await addExpense(id, {
        occurrenceDate: formData.occurrenceDate,
        amount: parseFloat(formData.amount) || 0,
        description: formData.description,
      });
      navigate(`/projects/${id}`);
    } catch (err) {
      setError('経費データの追加に失敗しました');
    }
  };

  return (
    <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2 }}>
      <Typography variant="h4" gutterBottom>経費入力</Typography>
      <Button variant="outlined" onClick={() => navigate(`/projects/${id}`)} sx={{ mb: 2 }}>
        工事詳細に戻る
      </Button>
      <Paper sx={{ p: 3 }}>
        {error && <Typography color="error">{error}</Typography>}
        <Box sx={{ mt: 2 }}>
          <Typography>発生日</Typography>
          <DatePicker
            selected={formData.occurrenceDate}
            onChange={date => setFormData(prev => ({ ...prev, occurrenceDate: date }))}
            dateFormat="yyyy/MM/dd"
            customInput={<TextField fullWidth margin="normal" required />}
          />
        </Box>
        <TextField
          label="金額 (円)"
          name="amount"
          type="number"
          value={formData.amount}
          onChange={handleChange}
          fullWidth
          margin="normal"
          required
        />
        <FormControl fullWidth margin="normal" required>
          <InputLabel>経費概要</InputLabel>
          <Select
            name="description"
            value={formData.description}
            onChange={handleChange}
          >
            <MenuItem value="外注費">外注費</MenuItem>
            <MenuItem value="労務費">労務費</MenuItem>
            <MenuItem value="材料費">材料費</MenuItem>
            <MenuItem value="その他">その他</MenuItem>
          </Select>
        </FormControl>
        <Button
          variant="contained"
          fullWidth
          sx={{ mt: 2 }}
          onClick={handleSubmit}
        >
          経費追加
        </Button>
      </Paper>
    </Box>
  );
};

export default ProjectExpenseAdd;

ここまでが生成AI(Grok3)での出力でした

今回は修正内容的にも表示する物を変えたので修正内容が多かったですね…
とりあえず実装していきます。(テストデータはまた別途作ってもらう予定です)

      ∧_∧
      (゚ Д゚ )ウボァー カタカタカタカタコピペコピペ
   _| ̄ ̄||_))_
 /旦|――||// /|
 | ̄ ̄ ̄ ̄ ̄| ̄| . |
 |_____|三|/

npm start...っと

よし、コンパイルは通りました
ただまぁ、工事一覧のページへ遷移できない問題は解決してない(しようとしてなかった)
のでここも修正しないといけないですね

とりあえず、表示されたエラー内容を入れてAI君に投げてみてどうかなと

エラー内容コピペ中...

エラー原因

エラーメッセージ「Cannot read properties of undefined (reading 'toLowerCase')」は、ProjectList.js のフィルタリング処理で、プロジェクトデータのフィールド(name、department、またはclient)が undefined の場合に toLowerCase() を呼び出そうとして発生しています。

fmfm
やっぱデータ無いのにデータ変換しようとしてるのが問題っぽいですね

サンプルデータを新しい構造の物に入替してから読み出ししてみます
これ、ついでにサンプル追加するスクリプト作ってもらった方がいいな…
手動追加めんどくさいし…
後次いでに、既存工事の変更だった場合に既存ドキュメントを修正するようにしてもらおう

エラー原因の既存ドキュメントは手動で削除しました

テストデータを現在の画面構成に合わせて再度作成するように変更

スクリプトでサンプルを登録できるように追加
登録済み工事の場合は、既存ドキュメントを変更する
フォルダ構成
C:\projects\cbit
├── src
│   ├── components
│   │   └── auth
│   │       ├── Login.js (Unchanged)
│   │       └── Signup.js (Unchanged)
│   ├── pages
│   │   ├── Home.js (Modified)               // ダッシュボードをテストデータ対応
│   │   ├── ProjectList.js (Modified)        // フィルタリングを安全に
│   │   ├── ProjectDetail.js (Unchanged)
│   │   ├── ProjectAdd.js (Unchanged)
│   │   └── ProjectExpenseAdd.js (Unchanged)
│   ├── utils
│   │   ├── firebase.js (Unchanged)
│   │   ├── data.js (Modified)               // 原価内訳取得関数追加
│   │   └── addSampleData.js (Modified)      // データ取得時の検証追加、テストデータにprogress追加
│   ├── App.js (Unchanged)
├── .env
├── package.json

data.js(Modify)

変更点:原価内訳(expenses の description ごとの合計)を取得する getCostBreakdown 関数を追加。fetchProjectData に progress フィールドのデフォルト値を設定。

data.js
import { collection, getDocs, addDoc, getDoc, doc } from 'firebase/firestore';
import { db } from './firebase';

export const fetchProjectData = async () => {
  try {
    const querySnapshot = await getDocs(collection(db, 'projects'));
    const projects = querySnapshot.docs
      .map(doc => ({ id: doc.id, ...doc.data() }))
      .filter(project => {
        return (
          typeof project.name === 'string' &&
          project.name &&
          typeof project.department === 'string' &&
          project.department &&
          typeof project.contractAmountIncTax === 'number' &&
          project.startDate &&
          project.endDate &&
          typeof project.accountingStandard === 'string' &&
          ['completion', 'progress'].includes(project.accountingStandard)
        );
      })
      .map(project => ({
        ...project,
        client: project.client || '',
        location: project.location || '',
        budget: project.budget || 0,
        contractAmountExTax: project.contractAmountExTax || 0,
        progress: Number(project.progress) || 0, // 進捗のデフォルト値
      }));
    return projects;
  } catch (error) {
    console.error('データ取得エラー:', error);
    return [];
  }
};

export const fetchProjectById = async (id) => {
  try {
    const docRef = doc(db, 'projects', id);
    const docSnap = await getDoc(docRef);
    if (docSnap.exists()) {
      const project = { id: docSnap.id, ...docSnap.data() };
      if (
        typeof project.name !== 'string' ||
        !project.name ||
        typeof project.department !== 'string' ||
        !project.department ||
        typeof project.contractAmountIncTax !== 'number' ||
        !project.startDate ||
        !project.endDate ||
        !['completion', 'progress'].includes(project.accountingStandard)
      ) {
        throw new Error('無効な工事データ');
      }
      return {
        ...project,
        client: project.client || '',
        location: project.location || '',
        budget: project.budget || 0,
        contractAmountExTax: project.contractAmountExTax || 0,
        progress: Number(project.progress) || 0,
      };
    } else {
      throw new Error('工事が見つかりません');
    }
  } catch (error) {
    console.error('工事詳細取得エラー:', error);
    return null;
  }
};

export const addProject = async (projectData) => {
  try {
    await addDoc(collection(db, 'projects'), {
      name: projectData.name,
      department: projectData.department,
      contractAmountExTax: projectData.contractAmountExTax || 0,
      contractAmountIncTax: projectData.contractAmountIncTax || 0,
      budget: projectData.budget || 0,
      client: projectData.client || '',
      location: projectData.location || '',
      startDate: projectData.startDate,
      endDate: projectData.endDate,
      accountingStandard: projectData.accountingStandard,
      progress: Number(projectData.progress) || 0, // 進捗追加
    });
    console.log('工事データを追加しました');
  } catch (error) {
    console.error('データ追加エラー:', error);
  }
};

export const addExpense = async (projectId, expenseData) => {
  try {
    const expenseRef = collection(db, 'projects', projectId, 'expenses');
    await addDoc(expenseRef, {
      occurrenceDate: expenseData.occurrenceDate,
      amount: expenseData.amount || 0,
      description: expenseData.description,
    });
    console.log('経費を追加しました');
  } catch (error) {
    console.error('経費追加エラー:', error);
  }
};

export const getTotalExpenses = async (projectId) => {
  try {
    const expenseRef = collection(db, 'projects', projectId, 'expenses');
    const querySnapshot = await getDocs(expenseRef);
    if (querySnapshot.empty) return 0;
    return querySnapshot.docs.reduce((sum, doc) => sum + (doc.data().amount || 0), 0);
  } catch (error) {
    console.error('経費合算エラー:', error);
    return 0;
  }
};

export const getCostBreakdown = async (projectId) => {
  try {
    const expenseRef = collection(db, 'projects', projectId, 'expenses');
    const querySnapshot = await getDocs(expenseRef);
    const breakdown = {
      outsourcing: 0,
      labor: 0,
      material: 0,
      other: 0,
    };
    querySnapshot.forEach(doc => {
      const expense = doc.data();
      const amount = Number(expense.amount) || 0;
      switch (expense.description) {
        case '外注費':
          breakdown.outsourcing += amount;
          break;
        case '労務費':
          breakdown.labor += amount;
          break;
        case '材料費':
          breakdown.material += amount;
          break;
        case 'その他':
          breakdown.other += amount;
          break;
        default:
          break;
      }
    });
    return breakdown;
  } catch (error) {
    console.error('原価内訳取得エラー:', error);
    return { outsourcing: 0, labor: 0, material: 0, other: 0 };
  }
};

Home.js(Modify)

変更点:ダッシュボードのデータをテストデータから抽出。getTotalExpenses と getCostBreakdown を使用し、売上、原価合計、粗利益、予算、進捗、原価内訳を計算。costs オブジェクトを getCostBreakdown で置き換え。

Home.js
import React, { useEffect, useState } from 'react';
import { Bar, Pie } from 'react-chartjs-2';
import { Chart as ChartJS, registerables } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { auth } from '../utils/firebase';
import { signOut } from 'firebase/auth';
import { useNavigate } from 'react-router-dom';
import { Box, Typography, Button, Grid, Paper, CircularProgress } from '@mui/material';
import { fetchProjectData, getTotalExpenses, getCostBreakdown } from '../utils/data';

ChartJS.register(...registerables, ChartDataLabels);

const Home = () => {
  const [projects, setProjects] = useState([]);
  const [loading, setLoading] = useState(true);
  const navigate = useNavigate();

  useEffect(() => {
    const loadData = async () => {
      setLoading(true);
      const data = await fetchProjectData();
      const projectsWithExpenses = await Promise.all(data.map(async (project) => {
        const totalExpenses = await getTotalExpenses(project.id);
        const costBreakdown = await getCostBreakdown(project.id);
        return { ...project, totalExpenses, costBreakdown };
      }));
      setProjects(projectsWithExpenses);
      setLoading(false);
    };
    loadData();
  }, []);

  const handleLogout = async () => {
    await signOut(auth);
    navigate('/login');
  };

  const totalSales = projects.reduce((sum, p) => sum + (p.contractAmountIncTax || 0), 0) / 10000;
  const totalCosts = projects.reduce((sum, p) => sum + (p.totalExpenses || 0), 0) / 10000;

  const budgetData = {
    labels: projects.map(p => p.name),
    datasets: [
      {
        label: '予算 (万円)',
        data: projects.map(p => (p.budget || 0) / 10000),
        backgroundColor: 'rgba(75, 192, 192, 0.6)',
      },
      {
        label: '使用済み経費 (万円)',
        data: projects.map(p => (p.totalExpenses || 0) / 10000),
        backgroundColor: 'rgba(255, 99, 132, 0.6)',
      },
    ],
  };

  const budgetOptions = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: { position: 'top' },
    },
    scales: {
      y: {
        type: 'linear',
        display: true,
        position: 'left',
        title: { display: true, text: '金額 (万円)' },
      },
    },
  };

  const progressData = {
    labels: projects.map(p => p.name),
    datasets: [
      {
        label: '進捗 (%)',
        data: projects.map(p => p.progress || 0),
        backgroundColor: 'rgba(54, 162, 235, 0.6)',
      },
    ],
  };

  const costData = {
    labels: ['外注費', '労務費', '材料費', 'その他'],
    datasets: projects.map(p => ({
      label: p.name,
      data: [
        (p.costBreakdown?.outsourcing || 0) / 10000,
        (p.costBreakdown?.labor || 0) / 10000,
        (p.costBreakdown?.material || 0) / 10000,
        (p.costBreakdown?.other || 0) / 10000,
      ],
      backgroundColor: ['rgba(255, 99, 132, 0.6)', 'rgba(54, 162, 235, 0.6)', 'rgba(255, 206, 86, 0.6)', 'rgba(75, 192, 192, 0.6)'],
    })),
  };

  const costOptions = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: { position: 'right' },
      datalabels: { display: false },
    },
  };

  const chartHeight = 250;

  if (loading) {
    return (
      <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2, textAlign: 'center' }}>
        <CircularProgress />
        <Typography>読み込み中...</Typography>
      </Box>
    );
  }

  return (
    <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2 }}>
      <Typography variant="h4" gutterBottom>Construction BI Dashboard</Typography>
      <Button variant="contained" onClick={handleLogout} sx={{ mb: 2, mr: 2 }}>
        ログアウト
      </Button>
      <Button variant="outlined" onClick={() => navigate('/projects')} sx={{ mb: 2 }}>
        工事一覧
      </Button>
      <Paper sx={{ p: 2, mb: 3 }}>
        <Typography variant="h6">今期業績予定</Typography>
        <Typography>売上予定):{totalSales.toFixed(1)}万円</Typography>
        <Typography>原価合計{totalCosts.toFixed(1)}万円</Typography>
        <Typography>粗利益{(totalSales - totalCosts).toFixed(1)}万円</Typography>
      </Paper>
      <Grid container spacing={3}>
        <Grid item xs={12} md={6}>
          <Typography variant="h6">予算</Typography>
          <Box sx={{ height: chartHeight }}>
            <Bar data={budgetData} options={budgetOptions} />
          </Box>
        </Grid>
        <Grid item xs={12} md={6}>
          <Typography variant="h6">進捗</Typography>
          <Box sx={{ height: chartHeight }}>
            <Bar
              data={progressData}
              options={{
                responsive: true,
                maintainAspectRatio: false,
                indexAxis: 'y',
                scales: { x: { max: 100, beginAtZero: true } },
              }}
            />
          </Box>
        </Grid>
        <Grid item xs={12}>
          <Typography variant="h6">原価内訳</Typography>
          <Box sx={{ height: chartHeight }}>
            <Pie data={costData} options={costOptions} />
          </Box>
        </Grid>
      </Grid>
    </Box>
  );
};

export default Home;

ProjectList.js(Modify)

変更点:フィルタリング時に name、department、client が undefined や null の場合を安全に扱うように修正。ロード中の表示を追加

ProjectList.js
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, TextField, CircularProgress } from '@mui/material';
import { fetchProjectData, getTotalExpenses } from '../utils/data';

const ProjectList = () => {
  const [projects, setProjects] = useState([]);
  const [filteredProjects, setFilteredProjects] = useState([]);
  const [filters, setFilters] = useState({ name: '', department: '', client: '' });
  const [loading, setLoading] = useState(true);
  const navigate = useNavigate();

  useEffect(() => {
    const loadData = async () => {
      setLoading(true);
      const data = await fetchProjectData();
      const projectsWithExpenses = await Promise.all(data.map(async (project) => {
        const totalExpenses = await getTotalExpenses(project.id);
        return { ...project, totalExpenses: totalExpenses || 0 };
      }));
      setProjects(projectsWithExpenses);
      setFilteredProjects(projectsWithExpenses);
      setLoading(false);
    };
    loadData();
  }, []);

  useEffect(() => {
    const filtered = projects.filter(project =>
      (project.name || '').toLowerCase().includes(filters.name.toLowerCase()) &&
      (project.department || '').toLowerCase().includes(filters.department.toLowerCase()) &&
      (project.client || '').toLowerCase().includes(filters.client.toLowerCase())
    );
    setFilteredProjects(filtered);
  }, [filters, projects]);

  const handleFilterChange = (e) => {
    const { name, value } = e.target;
    setFilters(prev => ({ ...prev, [name]: value }));
  };

  if (loading) {
    return (
      <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2, textAlign: 'center' }}>
        <CircularProgress />
        <Typography>読み込み中...</Typography>
      </Box>
    );
  }

  return (
    <Box sx={{ maxWidth: 1200, mx: 'auto', mt: 8, p: 2 }}>
      <Typography variant="h4" gutterBottom>工事一覧</Typography>
      <Button variant="outlined" onClick={() => navigate('/projects/add')} sx={{ mb: 2 }}>
        工事追加
      </Button>
      <Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
        <TextField
          label="工事名で検索"
          name="name"
          value={filters.name}
          onChange={handleFilterChange}
          size="small"
        />
        <TextField
          label="部署で検索"
          name="department"
          value={filters.department}
          onChange={handleFilterChange}
          size="small"
        />
        <TextField
          label="発注者で検索"
          name="client"
          value={filters.client}
          onChange={handleFilterChange}
          size="small"
        />
      </Box>
      <TableContainer component={Paper}>
        <Table>
          <TableHead>
            <TableRow>
              <TableCell>工事名</TableCell>
              <TableCell align="right">部署</TableCell>
              <TableCell align="right">受注金額税込 (万円)</TableCell>
              <TableCell align="right">実行予算 (万円)</TableCell>
              <TableCell align="right">使用済み経費(コスト) (万円)</TableCell>
              <TableCell align="right">発注者</TableCell>
              <TableCell align="right">工事場所</TableCell>
              <TableCell align="right">工期開始</TableCell>
              <TableCell align="right">工期終了</TableCell>
              <TableCell align="right">会計基準</TableCell>
              <TableCell align="right">詳細</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {filteredProjects.map(project => (
              <TableRow key={project.id}>
                <TableCell>{project.name}</TableCell>
                <TableCell align="right">{project.department}</TableCell>
                <TableCell align="right">{(project.contractAmountIncTax / 10000).toFixed(1)}</TableCell>
                <TableCell align="right">{(project.budget / 10000).toFixed(1)}</TableCell>
                <TableCell align="right">{((project.totalExpenses || 0) / 10000).toFixed(1)}</TableCell>
                <TableCell align="right">{project.client}</TableCell>
                <TableCell align="right">{project.location}</TableCell>
                <TableCell align="right">{project.startDate ? new Date(project.startDate.toDate ? project.startDate.toDate() : project.startDate).toLocaleDateString('ja-JP') : '-'}</TableCell>
                <TableCell align="right">{project.endDate ? new Date(project.endDate.toDate ? project.endDate.toDate() : project.endDate).toLocaleDateString('ja-JP') : '-'}</TableCell>
                <TableCell align="right">{project.accountingStandard}</TableCell>
                <TableCell align="right">
                  <Button variant="outlined" onClick={() => navigate(`/projects/${project.id}`)}>
                    詳細
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
    </Box>
  );
};

export default ProjectList;

addSampleData.js(Modify)

変更点

  • 工事名(name)で既存ドキュメントを検索(query と getDocs)
  • 既存ドキュメントがあれば setDoc で更新、なければ addDoc で追加
  • expenses サブコレクションをクリア(deleteDoc)し、テストデータの経費を再登録
  • テストデータに progress フィールドを保持
addSampleData.js
const { getFirestore, collection, addDoc, setDoc, doc, getDocs, query, where, deleteDoc } = require('firebase/firestore');
const { initializeApp } = require('firebase/app');
require('dotenv').config();

const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID,
};

console.log('Firebase Config:', firebaseConfig);

const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

const testData = [
  {
    name: "工事A",
    department: "土木部",
    contractAmountExTax: 100000000,
    contractAmountIncTax: 110000000,
    budget: 90000000,
    client: "株式会社サンプル",
    location: "東京都中央区",
    startDate: Timestamp.fromDate(new Date(2025, 0, 1)),
    endDate: Timestamp.fromDate(new Date(2025, 11, 31)),
    accountingStandard: "completion",
    progress: 80,
    expenses: [
      {
        occurrenceDate: Timestamp.fromDate(new Date(2025, 1, 1)),
        amount: 20000000,
        description: "外注費",
      },
      {
        occurrenceDate: Timestamp.fromDate(new Date(2025, 2, 1)),
        amount: 15000000,
        description: "材料費",
      },
    ],
  },
  {
    name: "工事B",
    department: "建築部",
    contractAmountExTax: 80000000,
    contractAmountIncTax: 88000000,
    budget: 70000000,
    client: "建設興業株式会社",
    location: "大阪府大阪市",
    startDate: Timestamp.fromDate(new Date(2025, 3, 1)),
    endDate: Timestamp.fromDate(new Date(2025, 9, 31)),
    accountingStandard: "progress",
    progress: 50,
  },
];

async function addTestData() {
  try {
    for (const project of testData) {
      if (!project.name || !project.department || !project.contractAmountIncTax || !project.startDate || !project.endDate || !project.accountingStandard) {
        console.error(`Invalid project data for ${project.name}: missing required fields`);
        continue;
      }

      const projectData = {
        name: project.name,
        department: project.department,
        contractAmountExTax: Number(project.contractAmountExTax) || 0,
        contractAmountIncTax: Number(project.contractAmountIncTax) || 0,
        budget: Number(project.budget) || 0,
        client: project.client || '',
        location: project.location || '',
        startDate: project.startDate,
        endDate: project.endDate,
        accountingStandard: project.accountingStandard,
        progress: Number(project.progress) || 0,
      };

      // 工事名で既存ドキュメントを検索
      const projectsRef = collection(db, 'projects');
      const q = query(projectsRef, where('name', '==', project.name));
      const querySnapshot = await getDocs(q);

      let projectRef;
      if (!querySnapshot.empty) {
        // 既存ドキュメントを更新
        projectRef = doc(db, 'projects', querySnapshot.docs[0].id);
        await setDoc(projectRef, projectData);
        console.log(`Updated project: ${project.name} with ID: ${projectRef.id}`);
      } else {
        // 新規ドキュメントを追加
        projectRef = await addDoc(projectsRef, projectData);
        console.log(`Added project: ${project.name} with ID: ${projectRef.id}`);
      }

      // 既存のexpensesサブコレクションをクリア
      const expensesRef = collection(db, 'projects', projectRef.id, 'expenses');
      const existingExpenses = await getDocs(expensesRef);
      for (const expenseDoc of existingExpenses.docs) {
        await deleteDoc(doc(db, 'projects', projectRef.id, 'expenses', expenseDoc.id));
      }
      console.log(`Cleared existing expenses for ${project.name}`);

      // テストデータの経費を登録
      if (project.expenses && project.expenses.length > 0) {
        for (const expense of project.expenses) {
          if (!expense.occurrenceDate || !expense.amount || !expense.description) {
            console.error(`Invalid expense data for ${project.name}: missing required fields`);
            continue;
          }
          await addDoc(expensesRef, {
            occurrenceDate: expense.occurrenceDate,
            amount: Number(expense.amount) || 0,
            description: expense.description,
          });
          console.log(`Added expense for ${project.name}: ${expense.description}`);
        }
      }
    }
    console.log('All data processed successfully');
  } catch (error) {
    console.error('Error processing data:', error);
  }
}

addTestData();

以上、AIによる修正でした

いや、なっが…
とりあえず、コピペコピペでコンパイルは通りました。
ただ、サンプル追加のスクリプトはどうやって動かすんだ…?

とりあえず、動かし方もGrok3君に聞きました

node ./src/utils/addSampleData.js

実行してみた結果
image.png

とりあえずなんか通ったポイ…?

Firestore上のデータを確認すると
image.png

おー追加できたっぽいな、これで動かしてみたらどうなるだろう

npm startっと...

とりあえず画面は…

image.png

image.png

image.png

image.png

image.png

こんな感じになりました

とりあえず今回の目標としていた部分に関してはできたかなーと
色々と修正点も多そうですけどね

見えてる部分だと

  • 工事一覧の工期開始終了がInvalidDateになっているのと会計基準が英語になっている
  • 工事詳細の所に入力した経費一覧が欲しい(できれば検索できるようにしたい)
  • 工程表を追加できるようにしたい(実行予算から自動生成できればベスト)

ってところでしょうか
工程表の作成は長くなりそうだし、元の資料のデータ変換から考えなきゃだから
結構後回しかな…

後、次回は実際に入力してみて登録できるのかから進めてみたいと思います

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?