import React, { useState, useMemo } from 'react';
import {
Calendar as CalendarIcon, Users, Building2, Plus, Trash2, Edit3,
ChevronLeft, ChevronRight, DollarSign, Calculator, FileText,
AlertCircle, Search, HelpCircle, ArrowRight, Car, Clock, Compass,
Receipt, LayoutGrid, List, CheckCircle2, ChevronDown, ChevronUp, Eye,
Briefcase, Layers, TrendingUp, Shuffle, Tag, History, Sparkles
} from 'lucide-react';
// 請負現場(㎡単価・一式請負)の初期データ
const INITIAL_CONTRACTS = [
{
id: 'cnt-1',
siteName: '南草津物流倉庫 外壁工事',
client: '株式会社山田建設',
calcType: 'area', // 'area' (㎡単価) or 'lump' (一式)
area: 250,
unitPrice: 2000,
totalContractAmount: 500000, // 250㎡ × 2000円 = 50万円
monthlyBillings: {
'2024-08': 100000, // 8月度は10万請求
'2024-09': 58000, // 9月度は5.8万請求
},
memo: '足場解体後に最終残金請求予定。追加作業は常用として別途計上可能。'
}
];
const DEFAULT_WORKERS = [
{ id: 'w1', name: 'A君', dailyRate: 18000, overtimeRate: 2500, taxType: 'inclusive', taxRate: 10 }, // 税込(日当全額)
{ id: 'w2', name: 'B君', dailyRate: 20000, overtimeRate: 2800, taxType: 'exclusive', taxRate: 10 }, // 税抜(外税10%加算)
{ id: 'w3', name: 'C君', dailyRate: 16000, overtimeRate: 2200, taxType: 'inclusive', taxRate: 10 },
];
const DEFAULT_CLIENTS = [
{ id: 'c1', name: '株式会社山田建設', billingRate: 24000, kohaRate: 10000, overtimeRate: 3500, taxRate: 10 },
{ id: 'c2', name: '佐藤工務店', billingRate: 25000, kohaRate: 12000, overtimeRate: 4000, taxRate: 10 },
{ id: 'c3', name: '田中興業', billingRate: 23000, kohaRate: 8000, overtimeRate: 3000, taxRate: 10 },
];
// 検索時の文字ゆれ(全角半角・大文字小文字・スペース)を吸収する正規化関数
const normalizeSearchText = (str) => {
if (!str) return '';
return str
.toLowerCase()
.replace(/[A-Za-z0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
.replace(/[\s ]+/g, '')
.trim();
};
const INITIAL_RECORDS = [
{
id: 'rec-1',
date: '2024-07-27',
client: '株式会社山田建設',
siteName: '駅前ビル改修工事',
billingType: 'daily', // 常用現場
contractId: '',
billedCount: 2.0,
billingRate: 24000,
hasKoha: true,
kohaFee: 10000,
overtimeHours: 1.5,
overtimeRate: 3500,
parkingFee: 2400,
transportFee: 1500,
workerDetails: [
{ workerName: 'A君', workRole: '常用', count: 1.0, rate: 18000, otHours: 1.5, otRate: 2500, allowance: 1000, note: '運転手当+1000' },
{ workerName: 'B君', workRole: '常用', count: 1.5, rate: 20000, otHours: 0, otRate: 2800, allowance: 2000, note: '別現場移動手当' },
],
memo: '光波使用。午後はB君別現場へ移動あり。'
},
{
id: 'rec-2',
date: '2024-08-05',
client: '株式会社山田建設',
siteName: '南草津物流倉庫 外壁工事',
billingType: 'contract', // 請負単独現場
contractId: 'cnt-1',
billedCount: 0,
billingRate: 0,
hasKoha: false,
kohaFee: 0,
overtimeHours: 0,
overtimeRate: 0,
parkingFee: 1500,
transportFee: 0,
workerDetails: [
{ workerName: 'A君', workRole: '請負', count: 1.0, rate: 18000, otHours: 0, otRate: 2500, allowance: 0, note: '請負面出し' },
{ workerName: 'B君', workRole: '請負', count: 1.0, rate: 20000, otHours: 0, otRate: 2800, allowance: 0, note: '請負施工' },
],
memo: '請負施工専念日。実費P代のみ計上'
},
{
id: 'rec-3',
date: '2024-08-08',
client: '株式会社山田建設',
siteName: '南草津物流倉庫 外壁工事',
billingType: 'mixed', // 常用と請負の併用
contractId: 'cnt-1',
billedCount: 1.0, // 追加常用1人工分請求
billingRate: 24000,
hasKoha: false,
kohaFee: 0,
overtimeHours: 1.0,
overtimeRate: 3500,
parkingFee: 1800,
transportFee: 0,
workerDetails: [
{ workerName: 'A君', workRole: '請負', count: 1.0, rate: 18000, otHours: 0, otRate: 2500, allowance: 0, note: '請負㎡施工' },
{ workerName: 'B君', workRole: '常用', count: 1.0, rate: 20000, otHours: 1.0, otRate: 2800, allowance: 0, note: '元請け指示の追加下地補修' },
],
memo: '請負現場だが急遽元請け依頼でB君が常用作業対応(1人工請求)。'
}
];
const getClosingPeriod = (yearMonthStr) => {
const [year, month] = yearMonthStr.split('-').map(Number);
let prevYear = year;
let prevMonth = month - 1;
if (prevMonth === 0) {
prevMonth = 12;
prevYear -= 1;
}
const startDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-26`;
const endDate = `${year}-${String(month).padStart(2, '0')}-25`;
return {
label: `${year}年 ${month}月度`,
periodText: `${prevMonth}/26 〜 ${month}/25`,
startDate,
endDate,
year,
month
};
};
export default function App() {
const [records, setRecords] = useState(INITIAL_RECORDS);
const [workers, setWorkers] = useState(DEFAULT_WORKERS);
const [clients, setClients] = useState(DEFAULT_CLIENTS);
const [contracts, setContracts] = useState(INITIAL_CONTRACTS);
// 表示対象の月度(25日締め基準)
const [selectedMonth, setSelectedMonth] = useState('2024-08');
// メインタブ: 'daily', 'contracts', 'workers', 'clients', 'master'
const [activeTab, setActiveTab] = useState('daily');
// モーダル管理
const [isFormOpen, setIsFormOpen] = useState(false);
const [isContractModalOpen, setIsContractModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [editingContract, setEditingContract] = useState(null);
const [deleteTargetId, setDeleteTargetId] = useState(null);
const [searchKeyword, setSearchKeyword] = useState('');
// 現場名サジェスト用のUI制御
const [isSiteSuggestOpen, setIsSiteSuggestOpen] = useState(false);
const [formData, setFormData] = useState({
date: new Date().toISOString().slice(0, 10),
client: DEFAULT_CLIENTS[0]?.name || '',
siteName: '',
billingType: 'daily',
contractId: '',
billedCount: 2.0,
billingRate: DEFAULT_CLIENTS[0]?.billingRate || 24000,
hasKoha: false,
kohaFee: DEFAULT_CLIENTS[0]?.kohaRate || 10000,
overtimeHours: 0,
overtimeRate: DEFAULT_CLIENTS[0]?.overtimeRate || 3500,
parkingFee: 0,
transportFee: 0,
memo: '',
workerDetails: [
{ workerName: 'A君', workRole: '常用', count: 1.0, rate: 18000, otHours: 0, otRate: 2500, allowance: 0, note: '' }
]
});
const [contractFormData, setContractFormData] = useState({
siteName: '',
client: DEFAULT_CLIENTS[0]?.name || '',
calcType: 'area',
area: 200,
unitPrice: 2000,
totalContractAmount: 400000,
memo: ''
});
const period = useMemo(() => getClosingPeriod(selectedMonth), [selectedMonth]);
const handlePrevMonth = () => {
const [year, month] = selectedMonth.split('-').map(Number);
let prevYear = year;
let prevMonth = month - 1;
if (prevMonth === 0) {
prevMonth = 12;
prevYear -= 1;
}
setSelectedMonth(`${prevYear}-${String(prevMonth).padStart(2, '0')}`);
};
const handleNextMonth = () => {
const [year, month] = selectedMonth.split('-').map(Number);
let nextYear = year;
let nextMonth = month + 1;
if (nextMonth === 13) {
nextMonth = 1;
nextYear += 1;
}
setSelectedMonth(`${nextYear}-${String(nextMonth).padStart(2, '0')}`);
};
const existingSitesForCurrentClient = useMemo(() => {
const targetClient = formData.client;
const siteSet = new Set();
contracts.filter(c => c.client === targetClient).forEach(c => siteSet.add(c.siteName));
records.filter(r => r.client === targetClient).forEach(r => siteSet.add(r.siteName));
contracts.forEach(c => siteSet.add(c.siteName));
records.forEach(r => siteSet.add(r.siteName));
return Array.from(siteSet).filter(Boolean);
}, [records, contracts, formData.client]);
const recentSites = useMemo(() => {
const targetClient = formData.client;
const sorted = [...records]
.filter(r => !targetClient || r.client === targetClient)
.sort((a, b) => b.date.localeCompare(a.date));
const unique = [];
sorted.forEach(r => {
if (r.siteName && !unique.includes(r.siteName)) {
unique.push(r.siteName);
}
});
return unique.slice(0, 5);
}, [records, formData.client]);
const filteredSiteSuggestions = useMemo(() => {
if (!formData.siteName.trim()) {
return existingSitesForCurrentClient.slice(0, 8);
}
const query = normalizeSearchText(formData.siteName);
return existingSitesForCurrentClient.filter(site =>
normalizeSearchText(site).includes(query)
).slice(0, 8);
}, [existingSitesForCurrentClient, formData.siteName]);
const currentPeriodContractBillings = useMemo(() => {
return contracts.reduce((sum, cnt) => {
const b = Number(cnt.monthlyBillings?.[selectedMonth]) || 0;
return sum + b;
}, 0);
}, [contracts, selectedMonth]);
const currentPeriodRecords = useMemo(() => {
return records.filter(rec => {
const inPeriod = rec.date >= period.startDate && rec.date <= period.endDate;
if (!inPeriod) return false;
if (!searchKeyword.trim()) return true;
const kw = normalizeSearchText(searchKeyword);
return (
normalizeSearchText(rec.siteName).includes(kw) ||
normalizeSearchText(rec.client).includes(kw) ||
rec.workerDetails.some(w => normalizeSearchText(w.workerName).includes(kw))
);
}).sort((a, b) => a.date.localeCompare(b.date));
}, [records, period, searchKeyword]);
const summaryTotals = useMemo(() => {
let billedCount = 0;
let billedLabor = 0;
let billedKoha = 0;
let billedOT = 0;
let billedExp = 0;
let paidCount = 0;
let paidLabor = 0;
let paidOT = 0;
let paidAllowance = 0;
currentPeriodRecords.forEach(rec => {
if (rec.billingType === 'daily' || rec.billingType === 'mixed') {
const bCount = Number(rec.billedCount) || 0;
const bRate = Number(rec.billingRate) || 0;
billedCount += bCount;
billedLabor += bCount * bRate;
if (rec.hasKoha) billedKoha += Number(rec.kohaFee) || 0;
billedOT += (Number(rec.overtimeHours) || 0) * (Number(rec.overtimeRate) || 0);
}
billedExp += (Number(rec.parkingFee) || 0) + (Number(rec.transportFee) || 0);
rec.workerDetails.forEach(w => {
const c = Number(w.count) || 0;
const r = Number(w.rate) || 0;
const otH = Number(w.otHours) || 0;
const otR = Number(w.otRate) || 0;
const allw = Number(w.allowance) || 0;
paidCount += c;
paidLabor += c * r;
paidOT += otH * otR;
paidAllowance += allw;
});
});
const totalContractBilled = currentPeriodContractBillings;
const totalBilled = billedLabor + billedKoha + billedOT + billedExp + totalContractBilled;
// 取引先別の税率を反映した消費税合計
let billedTaxTotal = 0;
clients.forEach(c => {
const clientRecords = currentPeriodRecords.filter(r => r.client === c.name);
let clientSubtotal = 0;
clientRecords.forEach(r => {
if (r.billingType === 'daily' || r.billingType === 'mixed') {
clientSubtotal += (Number(r.billedCount) || 0) * (Number(r.billingRate) || 0);
if (r.hasKoha) clientSubtotal += (Number(r.kohaFee) || 0);
clientSubtotal += (Number(r.overtimeHours) || 0) * (Number(r.overtimeRate) || 0);
}
clientSubtotal += (Number(r.parkingFee) || 0) + (Number(r.transportFee) || 0);
});
contracts.filter(cnt => cnt.client === c.name).forEach(cnt => {
clientSubtotal += Number(cnt.monthlyBillings?.[selectedMonth]) || 0;
});
billedTaxTotal += Math.floor(clientSubtotal * ((c.taxRate ?? 10) / 100));
});
const totalBilledWithTax = totalBilled + billedTaxTotal;
// 職人給与(実支払額:税抜設定の職人は外税を加算)
let totalPaidWithTax = 0;
workers.forEach(w => {
let wBase = 0;
let wOT = 0;
let wAllw = 0;
currentPeriodRecords.forEach(r => {
r.workerDetails.filter(d => d.workerName === w.name).forEach(d => {
wBase += (Number(d.count) || 0) * (Number(d.rate) || 0);
wOT += (Number(d.otHours) || 0) * (Number(d.otRate) || 0);
wAllw += (Number(d.allowance) || 0);
});
});
const sub = wBase + wOT + wAllw;
if (w.taxType === 'exclusive') {
totalPaidWithTax += sub + Math.floor(sub * ((w.taxRate ?? 10) / 100));
} else {
totalPaidWithTax += sub;
}
});
const totalPaid = paidLabor + paidOT + paidAllowance;
const profit = totalBilled - totalPaid;
const profitMargin = totalBilled > 0 ? (profit / totalBilled) * 100 : 0;
return {
billedCount,
billedLabor,
billedKoha,
billedOT,
billedExp,
totalContractBilled,
totalBilled,
billedTaxTotal,
totalBilledWithTax,
paidCount,
paidLabor,
paidOT,
paidAllowance,
totalPaid,
totalPaidWithTax,
profit,
profitMargin,
diffCount: paidCount - billedCount
};
}, [currentPeriodRecords, currentPeriodContractBillings, clients, workers, contracts, selectedMonth]);
const workerSummaryList = useMemo(() => {
const map = {};
currentPeriodRecords.forEach(rec => {
rec.workerDetails.forEach(w => {
const masterWorker = workers.find(mw => mw.name === w.workerName);
const taxType = masterWorker?.taxType || 'inclusive';
const taxRate = masterWorker?.taxRate ?? 10;
if (!map[w.workerName]) {
map[w.workerName] = {
name: w.workerName,
taxType,
taxRate,
dates: new Set(),
totalCount: 0,
contractCount: 0,
dailyCount: 0,
basePay: 0,
otHours: 0,
otPay: 0,
allowancePay: 0,
subtotalPay: 0,
taxAmount: 0,
totalPay: 0,
items: []
};
}
const cnt = Number(w.count) || 0;
const base = cnt * (Number(w.rate) || 0);
const otH = Number(w.otHours) || 0;
const otP = otH * (Number(w.otRate) || 0);
const allw = Number(w.allowance) || 0;
const sub = base + otP + allw;
map[w.workerName].dates.add(rec.date);
map[w.workerName].totalCount += cnt;
if (w.workRole === '請負') {
map[w.workerName].contractCount += cnt;
} else {
map[w.workerName].dailyCount += cnt;
}
map[w.workerName].basePay += base;
map[w.workerName].otHours += otH;
map[w.workerName].otPay += otP;
map[w.workerName].allowancePay += allw;
map[w.workerName].subtotalPay += sub;
});
});
return Object.values(map).map(m => {
let tax = 0;
let total = m.subtotalPay;
if (m.taxType === 'exclusive') {
tax = Math.floor(m.subtotalPay * (m.taxRate / 100));
total = m.subtotalPay + tax;
}
return {
...m,
taxAmount: tax,
totalPay: total,
dayCount: m.dates.size
};
}).sort((a, b) => b.totalPay - a.totalPay);
}, [currentPeriodRecords, workers]);
const clientSummaryList = useMemo(() => {
const map = {};
currentPeriodRecords.forEach(rec => {
const masterClient = clients.find(mc => mc.name === rec.client);
const taxRate = masterClient?.taxRate ?? 10;
if (!map[rec.client]) {
map[rec.client] = {
clientName: rec.client,
taxRate,
siteCount: 0,
totalCount: 0,
laborAmount: 0,
contractAmount: 0,
kohaAmount: 0,
otAmount: 0,
expAmount: 0,
subtotalAmount: 0,
taxAmount: 0,
totalAmountWithTax: 0,
sites: []
};
}
const isDailyOrMixed = rec.billingType === 'daily' || rec.billingType === 'mixed';
const bCount = isDailyOrMixed ? (Number(rec.billedCount) || 0) : 0;
const bRate = isDailyOrMixed ? (Number(rec.billingRate) || 0) : 0;
const labor = bCount * bRate;
const koha = (isDailyOrMixed && rec.hasKoha) ? (Number(rec.kohaFee) || 0) : 0;
const ot = isDailyOrMixed ? ((Number(rec.overtimeHours) || 0) * (Number(rec.overtimeRate) || 0)) : 0;
const exp = (Number(rec.parkingFee) || 0) + (Number(rec.transportFee) || 0);
const subtotal = labor + koha + ot + exp;
map[rec.client].siteCount += 1;
map[rec.client].totalCount += bCount;
map[rec.client].laborAmount += labor;
map[rec.client].kohaAmount += koha;
map[rec.client].otAmount += ot;
map[rec.client].expAmount += exp;
map[rec.client].subtotalAmount += subtotal;
});
contracts.forEach(cnt => {
const monthBilled = Number(cnt.monthlyBillings?.[selectedMonth]) || 0;
if (monthBilled > 0) {
const masterClient = clients.find(mc => mc.name === cnt.client);
const taxRate = masterClient?.taxRate ?? 10;
if (!map[cnt.client]) {
map[cnt.client] = {
clientName: cnt.client,
taxRate,
siteCount: 0,
totalCount: 0,
laborAmount: 0,
contractAmount: 0,
kohaAmount: 0,
otAmount: 0,
expAmount: 0,
subtotalAmount: 0,
taxAmount: 0,
totalAmountWithTax: 0,
sites: []
};
}
map[cnt.client].contractAmount += monthBilled;
map[cnt.client].subtotalAmount += monthBilled;
}
});
return Object.values(map).map(item => {
const tax = Math.floor(item.subtotalAmount * (item.taxRate / 100));
return {
...item,
taxAmount: tax,
totalAmountWithTax: item.subtotalAmount + tax
};
}).sort((a, b) => b.totalAmountWithTax - a.totalAmountWithTax);
}, [currentPeriodRecords, contracts, clients, selectedMonth]);
const openNewRecordModal = () => {
setEditingRecord(null);
const defClient = clients[0] || { name: '', billingRate: 24000, kohaRate: 10000, overtimeRate: 3500 };
const defWorker = workers[0] || { name: 'A君', dailyRate: 18000, overtimeRate: 2500 };
setFormData({
date: new Date().toISOString().slice(0, 10),
client: defClient.name,
siteName: '',
billingType: 'daily',
contractId: '',
billedCount: 2.0,
billingRate: defClient.billingRate,
hasKoha: false,
kohaFee: defClient.kohaRate || 10000,
overtimeHours: 0,
overtimeRate: defClient.overtimeRate || 3500,
parkingFee: 0,
transportFee: 0,
memo: '',
workerDetails: [
{
workerName: defWorker.name,
workRole: '常用',
count: 1.0,
rate: defWorker.dailyRate,
otHours: 0,
otRate: defWorker.overtimeRate || 2500,
allowance: 0,
note: ''
}
]
});
setIsFormOpen(true);
};
const openEditRecordModal = (rec) => {
setEditingRecord(rec);
setFormData({
date: rec.date,
client: rec.client,
siteName: rec.siteName,
billingType: rec.billingType || 'daily',
contractId: rec.contractId || '',
billedCount: rec.billedCount ?? 2.0,
billingRate: rec.billingRate ?? 24000,
hasKoha: !!rec.hasKoha,
kohaFee: rec.kohaFee ?? 10000,
overtimeHours: rec.overtimeHours ?? 0,
overtimeRate: rec.overtimeRate ?? 3500,
parkingFee: rec.parkingFee ?? 0,
transportFee: rec.transportFee ?? 0,
memo: rec.memo || '',
workerDetails: rec.workerDetails.map(w => ({
...w,
workRole: w.workRole || (rec.billingType === 'contract' ? '請負' : '常用'),
otHours: w.otHours ?? 0,
otRate: w.otRate ?? 2500,
allowance: w.allowance ?? 0,
note: w.note || ''
}))
});
setIsFormOpen(true);
};
const handleClientChange = (clientName) => {
const target = clients.find(c => c.name === clientName);
setFormData(prev => ({
...prev,
client: clientName,
billingRate: target ? target.billingRate : prev.billingRate,
kohaFee: target ? target.kohaRate : prev.kohaFee,
overtimeRate: target ? target.overtimeRate : prev.overtimeRate,
}));
};
const handleWorkerNameChange = (idx, name) => {
const target = workers.find(w => w.name === name);
const updated = [...formData.workerDetails];
updated[idx] = {
...updated[idx],
workerName: name,
rate: target ? target.dailyRate : updated[idx].rate,
otRate: target ? target.overtimeRate : updated[idx].otRate,
};
setFormData({ ...formData, workerDetails: updated });
};
const addWorkerRow = () => {
const defaultRole = formData.billingType === 'contract' ? '請負' : '常用';
const firstWorker = workers[0] || { name: '職人', dailyRate: 18000, overtimeRate: 2500 };
setFormData(prev => ({
...prev,
workerDetails: [
...prev.workerDetails,
{
workerName: firstWorker.name,
workRole: defaultRole,
count: 1.0,
rate: firstWorker.dailyRate,
otHours: 0,
otRate: firstWorker.overtimeRate || 2500,
allowance: 0,
note: ''
}
]
}));
};
const removeWorkerRow = (idx) => {
if (formData.workerDetails.length <= 1) return;
setFormData(prev => ({
...prev,
workerDetails: prev.workerDetails.filter((_, i) => i !== idx)
}));
};
const handleSaveRecord = (e) => {
e.preventDefault();
if (!formData.siteName.trim()) return;
const payload = {
...formData,
billedCount: (formData.billingType === 'contract') ? 0 : Number(formData.billedCount) || 0,
billingRate: (formData.billingType === 'contract') ? 0 : Number(formData.billingRate) || 0,
kohaFee: Number(formData.kohaFee) || 0,
overtimeHours: (formData.billingType === 'contract') ? 0 : Number(formData.overtimeHours) || 0,
overtimeRate: Number(formData.overtimeRate) || 0,
parkingFee: Number(formData.parkingFee) || 0,
transportFee: Number(formData.transportFee) || 0,
workerDetails: formData.workerDetails.map(w => ({
...w,
count: Number(w.count) || 0,
rate: Number(w.rate) || 0,
otHours: Number(w.otHours) || 0,
otRate: Number(w.otRate) || 0,
allowance: Number(w.allowance) || 0,
}))
};
if (editingRecord) {
setRecords(prev => prev.map(r => r.id === editingRecord.id ? { ...payload, id: r.id } : r));
} else {
setRecords(prev => [{ ...payload, id: 'rec-' + Date.now() }, ...prev]);
}
setIsFormOpen(false);
};
const handleDeleteRecord = (id) => {
setRecords(prev => prev.filter(r => r.id !== id));
setDeleteTargetId(null);
};
const handleUpdateMonthlyContractBilling = (contractId, month, amount) => {
setContracts(prev => prev.map(cnt => {
if (cnt.id !== contractId) return cnt;
return {
...cnt,
monthlyBillings: {
...cnt.monthlyBillings,
[month]: Number(amount) || 0
}
};
}));
};
const openNewContractModal = () => {
setEditingContract(null);
setContractFormData({
siteName: '',
client: clients[0]?.name || '',
calcType: 'area',
area: 200,
unitPrice: 2000,
totalContractAmount: 400000,
memo: ''
});
setIsContractModalOpen(true);
};
const openEditContractModal = (cnt) => {
setEditingContract(cnt);
setContractFormData({
siteName: cnt.siteName,
client: cnt.client,
calcType: cnt.calcType || 'area',
area: cnt.area || 0,
unitPrice: cnt.unitPrice || 0,
totalContractAmount: cnt.totalContractAmount || 0,
memo: cnt.memo || ''
});
setIsContractModalOpen(true);
};
const saveContract = (e) => {
e.preventDefault();
if (!contractFormData.siteName.trim()) return;
let total = Number(contractFormData.totalContractAmount) || 0;
if (contractFormData.calcType === 'area') {
total = (Number(contractFormData.area) || 0) * (Number(contractFormData.unitPrice) || 0);
}
const payload = {
...contractFormData,
totalContractAmount: total,
area: Number(contractFormData.area) || 0,
unitPrice: Number(contractFormData.unitPrice) || 0,
};
if (editingContract) {
setContracts(prev => prev.map(c => c.id === editingContract.id ? { ...c, ...payload } : c));
} else {
setContracts(prev => [
...prev,
{
...payload,
id: 'cnt-' + Date.now(),
monthlyBillings: {}
}
]);
}
setIsContractModalOpen(false);
};
return (
{/* ナビゲーションバー */}
現場・人工管理システム
25日締め・常用&請負
常用人工請求・請負出来高・同一現場での併用作業を一括管理
{/* 25日締め月度セレクター & メインタブ */}
{period.label}
締め期間: {period.periodText}
{/* 取引先への請求総額 */}
取引先請求総額 (税込)
{summaryTotals.billedCount.toFixed(1)} 常用人工
¥
{summaryTotals.totalBilledWithTax.toLocaleString()}
税抜計: ¥{summaryTotals.totalBilled.toLocaleString()}
消費税: ¥{summaryTotals.billedTaxTotal.toLocaleString()}
{/* 職人への支払総額 */}
職人支払総額 (実支給)
{summaryTotals.paidCount.toFixed(1)} 人工
¥
{summaryTotals.totalPaidWithTax.toLocaleString()}
基本日当: ¥{summaryTotals.paidLabor.toLocaleString()}
{summaryTotals.paidOT > 0 && 残業: ¥{summaryTotals.paidOT.toLocaleString()}}
{summaryTotals.paidAllowance > 0 && 手当: ¥{summaryTotals.paidAllowance.toLocaleString()}}
{/* 手元に残る粗利益 */}
手元に残る粗利益
利益率 {summaryTotals.profitMargin.toFixed(1)}%
= 0 ? 'text-amber-700' : 'text-rose-600'}`}>
¥
{summaryTotals.profit.toLocaleString()}
税抜売上総額(常用+請負+実費) − 職人給与
{/* 請負出来高合計 */}
当月請負出来高
{period.label}
¥
{summaryTotals.totalContractBilled.toLocaleString()}
㎡請負・一式の当月請求分
{activeTab === 'daily' && (
{currentPeriodRecords.length === 0 ? (
この期間({period.label})の現場日報はありません
「日報を入力」ボタンから、当日の現場名や常用・請負・併用区分を登録してください。
) : (
{currentPeriodRecords.map(rec => {
const isMixed = rec.billingType === 'mixed';
const isContractOnly = rec.billingType === 'contract';
const isDailyOnly = rec.billingType === 'daily';
const workerCount = rec.workerDetails.reduce((sum, w) => sum + (Number(w.count) || 0), 0);
const workerPay = rec.workerDetails.reduce((sum, w) => {
const base = (Number(w.count) || 0) * (Number(w.rate) || 0);
const ot = (Number(w.otHours) || 0) * (Number(w.otRate) || 0);
const allw = Number(w.allowance) || 0;
return sum + base + ot + allw;
}, 0);
const laborBilled = isContractOnly ? 0 : (Number(rec.billedCount) || 0) * (Number(rec.billingRate) || 0);
const kohaBilled = (isDailyOnly || isMixed) && rec.hasKoha ? (Number(rec.kohaFee) || 0) : 0;
const otBilled = (isDailyOnly || isMixed) ? (Number(rec.overtimeHours) || 0) * (Number(rec.overtimeRate) || 0) : 0;
const expBilled = (Number(rec.parkingFee) || 0) + (Number(rec.transportFee) || 0);
const billedToday = laborBilled + kohaBilled + otBilled + expBilled;
return (
{rec.date}
{rec.siteName}
{rec.client}
{isMixed && (
請負+常用(両方あり)
)}
{isContractOnly && (
請負単独現場
)}
{isDailyOnly && (
常用現場(人工請求)
)}
{rec.hasKoha && (
光波
)}
{Number(rec.overtimeHours) > 0 && (
残業{rec.overtimeHours}h
)}
{expBilled > 0 && (
実費有
)}
{isMixed ? (
<> 請負現場での常用請求>
) : isContractOnly ? (
<> 請負出来高計上>
) : (
<> 取引先への常用請求>
)}
{isContractOnly ? '出来高一括' : `常用 ${rec.billedCount} 人工`}
{isContractOnly ? (
請負工事に専念。請求は「請負管理」タブの月度出来高に集計されます。
) : (
<>
{isMixed ? '追加常用人工代' : '常用人工代'} ({rec.billedCount}人 × ¥{Number(rec.billingRate).toLocaleString()}):
¥{laborBilled.toLocaleString()}
{rec.hasKoha && (
光波持込料:
¥{kohaBilled.toLocaleString()}
)}
{Number(rec.overtimeHours) > 0 && (
残業 ({rec.overtimeHours}h × ¥{Number(rec.overtimeRate).toLocaleString()}):
¥{otBilled.toLocaleString()}
)}
>
)}
{expBilled > 0 && (
実費立替 (P代/交通費):
¥{expBilled.toLocaleString()}
)}
{isContractOnly ? '当日実費請求額:' : '当日請求計上額:'}
¥{billedToday.toLocaleString()}
出役職人・作業区分・支給額
合計: {workerCount.toFixed(1)} 人工 (¥{workerPay.toLocaleString()})
{rec.workerDetails.map((w, idx) => {
const base = (Number(w.count) || 0) * (Number(w.rate) || 0);
const ot = (Number(w.otHours) || 0) * (Number(w.otRate) || 0);
const allw = Number(w.allowance) || 0;
const subtotal = base + ot + allw;
const role = w.workRole || (rec.billingType === 'contract' ? '請負' : '常用');
return (
{w.workerName}
{role === '請負' ? '請負作業' : '常用作業'}
{w.count} 人工
{Number(w.otHours) > 0 && (
残業{w.otHours}h (+¥{ot.toLocaleString()})
)}
{allw > 0 && (
手当+¥{allw.toLocaleString()}
)}
¥{subtotal.toLocaleString()}
{w.note && (
作業/手当メモ: {w.note}
)}
);
})}
{rec.memo && 📝 現場メモ: {rec.memo}}
職人人件費計: ¥{workerPay.toLocaleString()}
);
})}
)}
)}
{activeTab === 'contracts' && (
請負現場・出来高請求管理
請負総額(㎡×単価)と各月ごとの請求額、さらに同一現場で発生した「追加常用請求」も連動して確認できます
{contracts.map(cnt => {
const allBilled = Object.values(cnt.monthlyBillings || {}).reduce((s, a) => s + (Number(a) || 0), 0);
const remaining = cnt.totalContractAmount - allBilled;
const currentMonthBilled = cnt.monthlyBillings?.[selectedMonth] || 0;
const siteRecords = records.filter(r => r.siteName === cnt.siteName);
const totalExtraDailyBilled = siteRecords.reduce((sum, r) => {
if (r.billingType === 'mixed' || r.billingType === 'daily') {
const l = (Number(r.billedCount) || 0) * (Number(r.billingRate) || 0);
const k = r.hasKoha ? (Number(r.kohaFee) || 0) : 0;
const ot = (Number(r.overtimeHours) || 0) * (Number(r.overtimeRate) || 0);
return sum + l + k + ot;
}
return sum;
}, 0);
const totalLaborCost = siteRecords.reduce((sum, r) => {
return sum + r.workerDetails.reduce((wSum, w) => {
const base = (Number(w.count) || 0) * (Number(w.rate) || 0);
const ot = (Number(w.otHours) || 0) * (Number(w.otRate) || 0);
return wSum + base + ot + (Number(w.allowance) || 0);
}, 0);
}, 0);
const totalSiteRevenue = allBilled + totalExtraDailyBilled;
const siteProfit = totalSiteRevenue - totalLaborCost;
return (
{cnt.client}
{cnt.siteName}
{cnt.calcType === 'area' ? (
施工面積: {cnt.area} ㎡ × 単価: ¥{Number(cnt.unitPrice).toLocaleString()}/㎡
) : (
一式請負契約
)}
{cnt.memo && ({cnt.memo})}
請負契約総額
¥{cnt.totalContractAmount.toLocaleString()}
請負累計請求済み額
¥{allBilled.toLocaleString()}
残り請負請求残高
¥{remaining.toLocaleString()}
現場トータル粗利
= 0 ? 'text-amber-700' : 'text-rose-600'}`}>
¥{siteProfit.toLocaleString()}
(追加常用¥{totalExtraDailyBilled.toLocaleString()}含む)
);
})}
)}
{activeTab === 'workers' && (
職人別 支給額集計({period.label})
職人ごとに設定された税込・税抜(外税)区分に基づいて正確に実支給額を算出
| 職人名 |
税区分 |
出役日数 |
常用人工 |
請負人工 |
合計人工 |
基本計+手当 |
消費税分 |
実支給額 (振込額) |
{workerSummaryList.map(w => (
| {w.name} |
{w.taxType === 'exclusive' ? (
税抜 ({w.taxRate}%)
) : (
税込
)}
|
{w.dayCount} 日 |
{w.dailyCount.toFixed(2)} |
{w.contractCount.toFixed(2)} |
{w.totalCount.toFixed(2)} 人
|
¥{w.subtotalPay.toLocaleString()}
|
{w.taxAmount > 0 ? `+¥${w.taxAmount.toLocaleString()}` : '¥0'}
|
¥{w.totalPay.toLocaleString()}
|
))}
| 合計 |
- |
- |
- |
- |
{summaryTotals.paidCount.toFixed(2)} 人
|
¥{summaryTotals.totalPaid.toLocaleString()}
|
¥{(summaryTotals.totalPaidWithTax - summaryTotals.totalPaid).toLocaleString()}
|
¥{summaryTotals.totalPaidWithTax.toLocaleString()}
|
)}
{activeTab === 'clients' && (
取引先別 請求集計({period.label})
税抜金額で集計し、各社ごとの設定税率(10%/8%/0%)に基づいた消費税額と税込請求額を算出
| 取引先名 |
税率 |
常用人工 |
常用金額 (税抜) |
請負出来高 (税抜) |
光波/残業/実費 |
税抜小計 |
消費税額 |
税込請求総額 |
{clientSummaryList.map(c => {
const others = c.kohaAmount + c.otAmount + c.expAmount;
return (
| {c.clientName} |
{c.taxRate}%
|
{c.totalCount.toFixed(2)}
|
¥{c.laborAmount.toLocaleString()}
|
{c.contractAmount > 0 ? `¥${c.contractAmount.toLocaleString()}` : '-'}
|
{others > 0 ? `¥${others.toLocaleString()}` : '-'}
|
¥{c.subtotalAmount.toLocaleString()}
|
¥{c.taxAmount.toLocaleString()}
|
¥{c.totalAmountWithTax.toLocaleString()}
|
);
})}
| 合計 |
- |
{summaryTotals.billedCount.toFixed(2)} 人
|
¥{summaryTotals.billedLabor.toLocaleString()}
|
¥{summaryTotals.totalContractBilled.toLocaleString()}
|
¥{(summaryTotals.billedKoha + summaryTotals.billedOT + summaryTotals.billedExp).toLocaleString()}
|
¥{summaryTotals.totalBilled.toLocaleString()}
|
¥{summaryTotals.billedTaxTotal.toLocaleString()}
|
¥{summaryTotals.totalBilledWithTax.toLocaleString()}
|
)}
{activeTab === 'master' && (
職人マスター設定
職人ごとに「税込」または「税抜(外税計算)」を選択できます。
{workers.map((w, i) => (
))}
取引先マスター設定
金額はすべて【税抜】で入力してください。適用消費税率も会社別に選択できます。
{clients.map((c, i) => (
))}
)}
{isFormOpen && (
{editingRecord ? '現場日報を編集' : '現場日報を登録'}
)}
{isContractModalOpen && (
)}
{deleteTargetId && (
この現場記録を削除しますか?
削除すると元に戻せません。集計からも除外されます。
)}
);
}