All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
939 lines
27 KiB
Vue
939 lines
27 KiB
Vue
<template>
|
||
<div class="projects-page">
|
||
<!-- Header -->
|
||
<div class="commercial-header">
|
||
<div class="commercial-icon">💼</div>
|
||
<div class="commercial-desc">商业用户专属功能,包含项目核算、成本分析等工具</div>
|
||
</div>
|
||
|
||
<!-- Project List -->
|
||
<div class="toolbar-sticky">
|
||
<div class="toolbar-inner">
|
||
<h3 class="page-title">📊 服务项目成本利润分析</h3>
|
||
<button class="btn-primary btn-sm" @click="handleCreateProject">+ 新增项目</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!selectedProject" class="project-list">
|
||
<!-- Demo project (first one, or fallback) -->
|
||
<div v-if="demoProject" class="project-card demo-card" @click="selectDemoProject">
|
||
<div class="proj-header">
|
||
<span class="proj-name">{{ demoProject.name }}</span>
|
||
<span class="proj-badge">体验</span>
|
||
</div>
|
||
<div class="proj-summary">
|
||
<span>点击体验成本利润分析</span>
|
||
</div>
|
||
</div>
|
||
<!-- Real projects (exclude demo) -->
|
||
<div
|
||
v-for="p in userProjects"
|
||
:key="p._id || p.id"
|
||
class="project-card"
|
||
@click="selectProject(p)"
|
||
>
|
||
<div class="proj-header">
|
||
<span class="proj-name">{{ p.name }}</span>
|
||
<span class="proj-date">{{ formatDate(p.updated_at || p.created_at) }}</span>
|
||
</div>
|
||
<div class="proj-summary">
|
||
<span>成分: {{ (p.ingredients || []).length }} 种</span>
|
||
<span class="proj-cost" v-if="p.ingredients && p.ingredients.length">
|
||
成本 {{ oils.fmtPrice(oils.calcCost(p.ingredients)) }}
|
||
</span>
|
||
</div>
|
||
<div class="proj-actions proj-actions-hover" @click.stop>
|
||
<button class="btn-icon-sm" @click="deleteProject(p)" title="删除">🗑️</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Project Detail -->
|
||
<div v-if="selectedProject" class="project-detail">
|
||
<div class="detail-toolbar">
|
||
<button class="btn-back" @click="selectedProject = null">← 返回列表</button>
|
||
<input
|
||
v-model="selectedProject.name"
|
||
class="proj-name-input"
|
||
@blur="saveProject"
|
||
/>
|
||
<button class="btn-outline btn-sm" :disabled="isDemoMode && !auth.isAdmin" @click="importFromRecipe">📋 从配方导入</button>
|
||
</div>
|
||
|
||
<!-- Ingredients Table -->
|
||
<div class="ingredients-section">
|
||
<div class="section-header-row">
|
||
<h4>🧴 配方成分</h4>
|
||
<div class="section-actions">
|
||
<button v-if="!isDemoMode || auth.isAdmin" class="btn-outline btn-sm" @click="addIngredient">+ 添加精油</button>
|
||
</div>
|
||
</div>
|
||
<table class="ingredients-table">
|
||
<thead>
|
||
<tr>
|
||
<th>精油</th>
|
||
<th>用量</th>
|
||
<th>每滴</th>
|
||
<th>小计</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(ing, i) in selectedProject.ingredients" :key="i" :class="{ 'readonly-row': isDemoMode && !auth.isAdmin }">
|
||
<td>
|
||
<template v-if="isDemoMode && !auth.isAdmin">
|
||
<span class="readonly-oil">{{ ing.oil || '—' }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<select v-model="ing.oil" class="form-select" @change="saveProject">
|
||
<option value="">— 选择精油 —</option>
|
||
<option v-for="name in oils.oilNames" :key="name" :value="name">{{ name }}</option>
|
||
</select>
|
||
</template>
|
||
</td>
|
||
<td>
|
||
<template v-if="isDemoMode && !auth.isAdmin">
|
||
<span class="readonly-drops">{{ ing.drops }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<input v-model.number="ing.drops" type="number" min="0" step="0.5" class="drops-input" @change="saveProject" />
|
||
</template>
|
||
</td>
|
||
<td class="cell-ppd">{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '—' }}</td>
|
||
<td class="cell-subtotal">{{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '—' }}</td>
|
||
<td><button v-if="!isDemoMode || auth.isAdmin" class="remove-btn" @click="removeIngredient(i)">×</button></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<table class="ingredients-table total-table">
|
||
<tr>
|
||
<td class="total-label-cell">配方总成本</td>
|
||
<td></td>
|
||
<td></td>
|
||
<td class="total-price-cell">{{ oils.fmtPrice(materialCost) }}</td>
|
||
<td></td>
|
||
</tr>
|
||
</table>
|
||
|
||
<!-- Consumption Analysis -->
|
||
<div v-if="consumptionData.length" class="consumption-section" style="margin-top:12px">
|
||
<h4>🧪 消耗分析</h4>
|
||
<table class="ingredients-table">
|
||
<thead>
|
||
<tr><th>精油</th><th>单次用量</th><th>瓶装容量</th><th>可做次数</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="c in consumptionData" :key="c.oil" :class="{ 'limit-oil': c.isLimit }">
|
||
<td>{{ c.oil }}</td>
|
||
<td>{{ c.drops }}滴</td>
|
||
<td>{{ c.bottleDrops }}滴</td>
|
||
<td>{{ c.sessions }}次</td>
|
||
<td></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="consumption-summary">
|
||
<span v-if="allSameSession">可做 <strong>{{ maxSessions }}</strong> 次</span>
|
||
<span v-else>⚠️ <strong>{{ limitingOil }}</strong> 最先消耗完,可做 <strong>{{ maxSessions }}</strong> 次</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pricing + Profit side by side -->
|
||
<div class="price-profit-row">
|
||
<div class="pricing-col">
|
||
<h4>💰 价格计算</h4>
|
||
<div class="price-row">
|
||
<span class="price-label">原料成本</span>
|
||
<span class="price-val-box">{{ oils.fmtPrice(materialCost) }}</span>
|
||
</div>
|
||
<div class="price-row">
|
||
<span class="price-label">包装费用</span>
|
||
<div class="price-input-wrap"><span>¥</span><input v-model.number="selectedProject.packaging_cost" type="number" class="form-input-inline" placeholder="0" @focus="clearZero($event)" @change="saveProject" /></div>
|
||
</div>
|
||
<div class="price-row">
|
||
<span class="price-label">人工费用</span>
|
||
<div class="price-input-wrap"><span>¥</span><input v-model.number="selectedProject.labor_cost" type="number" class="form-input-inline" placeholder="0" @focus="clearZero($event)" @change="saveProject" /></div>
|
||
</div>
|
||
<div class="price-row">
|
||
<span class="price-label">其他成本</span>
|
||
<div class="price-input-wrap"><span>¥</span><input v-model.number="selectedProject.other_cost" type="number" class="form-input-inline" placeholder="0" @focus="clearZero($event)" @change="saveProject" /></div>
|
||
</div>
|
||
<div class="price-row total">
|
||
<span class="price-label">总成本</span>
|
||
<span class="price-val-box">{{ oils.fmtPrice(totalCost) }}</span>
|
||
</div>
|
||
<div class="price-row">
|
||
<span class="price-label">售价</span>
|
||
<div class="price-input-wrap"><span>¥</span><input v-model.number="selectedProject.selling_price" type="number" class="form-input-inline" @change="saveProject" /></div>
|
||
</div>
|
||
<div class="price-row">
|
||
<span class="price-label">批量数量</span>
|
||
<input v-model.number="selectedProject.quantity" type="number" min="1" class="form-input-inline" @change="saveProject" />
|
||
</div>
|
||
</div>
|
||
<div class="profit-col">
|
||
<h4>📊 利润分析</h4>
|
||
<div class="profit-card">
|
||
<div class="profit-card-label">单件利润</div>
|
||
<div class="profit-card-value" :class="{ negative: unitProfit < 0 }">{{ oils.fmtPrice(unitProfit) }}</div>
|
||
</div>
|
||
<div class="profit-card">
|
||
<div class="profit-card-label">利润率</div>
|
||
<div class="profit-card-value" :class="{ negative: profitMargin < 0 }">{{ profitMargin.toFixed(1) }}%</div>
|
||
</div>
|
||
<div class="profit-card">
|
||
<div class="profit-card-label">批量总利润</div>
|
||
<div class="profit-card-value" :class="{ negative: batchProfit < 0 }">{{ oils.fmtPrice(batchProfit) }}</div>
|
||
</div>
|
||
<div class="profit-card">
|
||
<div class="profit-card-label">批量总收入</div>
|
||
<div class="profit-card-value">{{ oils.fmtPrice(batchRevenue) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notes -->
|
||
<div class="notes-section">
|
||
<h4>📝 备注</h4>
|
||
<textarea
|
||
v-model="selectedProject.notes"
|
||
class="notes-textarea"
|
||
rows="3"
|
||
placeholder="项目备注..."
|
||
@blur="saveProject"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Import From Recipe Modal -->
|
||
<div v-if="showImportModal" class="overlay" @click.self="showImportModal = false">
|
||
<div class="overlay-panel">
|
||
<div class="overlay-header">
|
||
<h3>从配方导入</h3>
|
||
<button class="btn-close" @click="showImportModal = false">✕</button>
|
||
</div>
|
||
<div class="recipe-import-list">
|
||
<div
|
||
v-for="r in recipeStore.recipes"
|
||
:key="r._id"
|
||
class="import-item"
|
||
@click="doImport(r)"
|
||
>
|
||
<span class="import-name">{{ r.name }}</span>
|
||
<span class="import-count">{{ r.ingredients.length }} 种精油</span>
|
||
<span class="import-cost">{{ oils.fmtPrice(oils.calcCost(r.ingredients)) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useOilsStore } from '../stores/oils'
|
||
import { useRecipesStore } from '../stores/recipes'
|
||
import { useUiStore } from '../stores/ui'
|
||
import { api } from '../composables/useApi'
|
||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||
|
||
const auth = useAuthStore()
|
||
const oils = useOilsStore()
|
||
const recipeStore = useRecipesStore()
|
||
const ui = useUiStore()
|
||
const router = useRouter()
|
||
|
||
async function showCertPrompt() {
|
||
const ok = await showConfirm('此功能需要商业认证,是否前往申请认证?', { okText: '去认证', cancelText: '取消' })
|
||
if (ok) {
|
||
router.push('/mydiary?tab=account§ion=biz-cert')
|
||
}
|
||
}
|
||
|
||
const projects = ref([])
|
||
const selectedProject = ref(null)
|
||
const showImportModal = ref(false)
|
||
|
||
onMounted(async () => {
|
||
await loadProjects()
|
||
})
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
const res = await api('/api/projects')
|
||
if (res.ok) {
|
||
projects.value = await res.json()
|
||
}
|
||
} catch {
|
||
projects.value = []
|
||
}
|
||
}
|
||
|
||
// Demo = first project (芳香调理技术), managed by admin
|
||
const demoProject = computed(() => projects.value.find(p => p.name && p.name.includes('芳香调理')) || projects.value[0] || null)
|
||
const userProjects = computed(() => {
|
||
const demoId = demoProject.value?._id || demoProject.value?.id
|
||
return projects.value.filter(p => (p._id || p.id) !== demoId)
|
||
})
|
||
const isDemoMode = computed(() => selectedProject.value?._demo === true)
|
||
|
||
function selectDemoProject() {
|
||
const p = demoProject.value
|
||
if (!p) return
|
||
selectedProject.value = {
|
||
...p,
|
||
_demo: true,
|
||
ingredients: (p.ingredients || []).map(i => ({ ...i })),
|
||
packaging_cost: p.packaging_cost || 0,
|
||
labor_cost: p.labor_cost || 0,
|
||
other_cost: p.other_cost || 0,
|
||
selling_price: p.selling_price || 299,
|
||
quantity: p.quantity || 1,
|
||
notes: p.notes || '',
|
||
}
|
||
}
|
||
|
||
function handleCreateProject() {
|
||
if (!auth.isBusiness && !auth.isAdmin) {
|
||
showCertPrompt()
|
||
return
|
||
}
|
||
createProject()
|
||
}
|
||
|
||
async function createProject() {
|
||
const name = await showPrompt('项目名称:', '新项目')
|
||
if (!name) return
|
||
try {
|
||
const res = await api('/api/projects', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name,
|
||
ingredients: [],
|
||
packaging_cost: 0,
|
||
labor_cost: 0,
|
||
other_cost: 0,
|
||
selling_price: 299,
|
||
quantity: 1,
|
||
notes: '',
|
||
}),
|
||
})
|
||
if (res.ok) {
|
||
await loadProjects()
|
||
const data = await res.json()
|
||
selectedProject.value = projects.value.find(p => (p._id || p.id) === (data._id || data.id)) || null
|
||
ui.showToast('项目已创建')
|
||
}
|
||
} catch {
|
||
ui.showToast('创建失败')
|
||
}
|
||
}
|
||
|
||
function selectProject(p) {
|
||
if (!auth.isBusiness && !auth.isAdmin) {
|
||
showCertPrompt()
|
||
return
|
||
}
|
||
selectedProject.value = {
|
||
...p,
|
||
ingredients: (p.ingredients || []).map(i => ({ ...i })),
|
||
packaging_cost: p.packaging_cost || 0,
|
||
labor_cost: p.labor_cost || 0,
|
||
other_cost: p.other_cost || 0,
|
||
selling_price: p.selling_price || 299,
|
||
quantity: p.quantity || 1,
|
||
notes: p.notes || '',
|
||
}
|
||
}
|
||
|
||
async function saveProject() {
|
||
if (!selectedProject.value) return
|
||
// Demo mode for non-admin: only save locally, don't hit API
|
||
if (isDemoMode.value && !auth.isAdmin) return
|
||
const id = selectedProject.value._id || selectedProject.value.id
|
||
if (!id) return
|
||
try {
|
||
await api(`/api/projects/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(selectedProject.value),
|
||
})
|
||
await loadProjects()
|
||
} catch {
|
||
// silent save
|
||
}
|
||
}
|
||
|
||
async function deleteProject(p) {
|
||
const ok = await showConfirm(`确定删除项目 "${p.name}"?`)
|
||
if (!ok) return
|
||
const id = p._id || p.id
|
||
try {
|
||
await api(`/api/projects/${id}`, { method: 'DELETE' })
|
||
projects.value = projects.value.filter(proj => (proj._id || proj.id) !== id)
|
||
if (selectedProject.value && (selectedProject.value._id || selectedProject.value.id) === id) {
|
||
selectedProject.value = null
|
||
}
|
||
ui.showToast('已删除')
|
||
} catch {
|
||
ui.showToast('删除失败')
|
||
}
|
||
}
|
||
|
||
function addIngredient() {
|
||
if (!selectedProject.value) return
|
||
selectedProject.value.ingredients.push({ oil: '', drops: 1 })
|
||
}
|
||
|
||
function removeIngredient(index) {
|
||
selectedProject.value.ingredients.splice(index, 1)
|
||
saveProject()
|
||
}
|
||
|
||
function importFromRecipe() {
|
||
showImportModal.value = true
|
||
}
|
||
|
||
function doImport(recipe) {
|
||
if (!selectedProject.value) return
|
||
selectedProject.value.ingredients = recipe.ingredients.map(i => ({ ...i }))
|
||
showImportModal.value = false
|
||
saveProject()
|
||
ui.showToast(`已导入 "${recipe.name}" 的配方`)
|
||
}
|
||
|
||
const materialCost = computed(() => {
|
||
if (!selectedProject.value) return 0
|
||
return oils.calcCost(selectedProject.value.ingredients.filter(i => i.oil))
|
||
})
|
||
|
||
const totalCost = computed(() => {
|
||
if (!selectedProject.value) return 0
|
||
return materialCost.value +
|
||
(selectedProject.value.packaging_cost || 0) +
|
||
(selectedProject.value.labor_cost || 0) +
|
||
(selectedProject.value.other_cost || 0)
|
||
})
|
||
|
||
const unitProfit = computed(() => {
|
||
if (!selectedProject.value) return 0
|
||
return (selectedProject.value.selling_price || 0) - totalCost.value
|
||
})
|
||
|
||
const profitMargin = computed(() => {
|
||
if (!selectedProject.value || !selectedProject.value.selling_price) return 0
|
||
return (unitProfit.value / selectedProject.value.selling_price) * 100
|
||
})
|
||
|
||
const batchProfit = computed(() => {
|
||
return unitProfit.value * (selectedProject.value?.quantity || 1)
|
||
})
|
||
|
||
const batchRevenue = computed(() => {
|
||
return (selectedProject.value?.selling_price || 0) * (selectedProject.value?.quantity || 1)
|
||
})
|
||
|
||
const consumptionData = computed(() => {
|
||
if (!selectedProject.value) return []
|
||
const ings = (selectedProject.value.ingredients || []).filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
|
||
return ings.map(i => {
|
||
const meta = oils.oilsMeta[i.oil]
|
||
const bottleDrops = meta ? meta.dropCount : 0
|
||
const sessions = bottleDrops > 0 && i.drops > 0 ? Math.floor(bottleDrops / i.drops) : 0
|
||
return { oil: i.oil, drops: i.drops, bottleDrops, sessions, isLimit: false }
|
||
})
|
||
})
|
||
|
||
const limitingOil = computed(() => {
|
||
const data = consumptionData.value.filter(c => c.sessions > 0)
|
||
if (!data.length) return ''
|
||
const min = data.reduce((a, b) => a.sessions < b.sessions ? a : b)
|
||
min.isLimit = true
|
||
return min.oil
|
||
})
|
||
|
||
const allSameSession = computed(() => {
|
||
const data = consumptionData.value.filter(c => c.sessions > 0)
|
||
if (data.length <= 1) return true
|
||
return data.every(c => c.sessions === data[0].sessions)
|
||
})
|
||
|
||
const maxSessions = computed(() => {
|
||
const data = consumptionData.value.filter(c => c.sessions > 0)
|
||
if (!data.length) return 0
|
||
return Math.min(...data.map(c => c.sessions))
|
||
})
|
||
|
||
function clearZero(e) {
|
||
if (e.target.value === '0' || e.target.value === 0) e.target.value = ''
|
||
}
|
||
|
||
function formatDate(d) {
|
||
if (!d) return ''
|
||
return new Date(d).toLocaleDateString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.projects-page {
|
||
padding: 0 12px 24px;
|
||
}
|
||
|
||
.commercial-header {
|
||
text-align: center; padding: 24px 16px 16px; margin-bottom: 16px;
|
||
}
|
||
.commercial-icon { font-size: 48px; margin-bottom: 8px; }
|
||
.commercial-desc { font-size: 14px; color: var(--text-light, #999); }
|
||
.demo-card { border-style: dashed !important; opacity: 0.85; }
|
||
.proj-actions-hover { opacity: 0; transition: opacity 0.15s; }
|
||
.project-card:hover .proj-actions-hover { opacity: 1; }
|
||
|
||
.readonly-row { background: #f8f7f5; }
|
||
.readonly-oil { font-size: 13px; color: #6b6375; }
|
||
.readonly-drops { font-size: 13px; color: #3e3a44; font-weight: 500; }
|
||
|
||
.consumption-section { margin-bottom: 20px; padding: 14px; background: #f8f7f5; border-radius: 12px; border: 1.5px solid #e5e4e7; }
|
||
.consumption-section h4 { margin: 0 0 12px; font-size: 14px; color: #3e3a44; }
|
||
.consumption-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 10px; }
|
||
.consumption-table th { text-align: left; padding: 6px 8px; color: #999; font-size: 12px; border-bottom: 1px solid #eee; }
|
||
.consumption-table td { padding: 6px 8px; border-bottom: 1px solid #f5f5f5; }
|
||
.consumption-table .limit-oil { background: #fff3e0; font-weight: 600; }
|
||
.consumption-summary { font-size: 13px; color: #e65100; padding: 8px; background: #fff8e1; border-radius: 8px; }
|
||
.proj-badge {
|
||
font-size: 10px; background: #fff3e0; color: #e65100; padding: 2px 8px; border-radius: 8px;
|
||
}
|
||
|
||
.toolbar-sticky {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
background: linear-gradient(135deg, #f0faf5 0%, #e8f0e8 100%);
|
||
margin: 0 -12px;
|
||
padding: 0 12px;
|
||
border-bottom: 1.5px solid #d4e8d4;
|
||
}
|
||
.toolbar-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 0;
|
||
}
|
||
|
||
.page-title {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.project-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.project-card {
|
||
padding: 14px 16px;
|
||
background: #fff;
|
||
border: 1.5px solid #e5e4e7;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
position: relative;
|
||
}
|
||
|
||
.project-card:hover {
|
||
border-color: #7ec6a4;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.proj-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.proj-name {
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.proj-date {
|
||
font-size: 12px;
|
||
color: #b0aab5;
|
||
}
|
||
|
||
.proj-summary {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 13px;
|
||
color: #6b6375;
|
||
}
|
||
|
||
.proj-cost {
|
||
color: #4a9d7e;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.proj-actions {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.project-card:hover .proj-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Detail */
|
||
.detail-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 18px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.btn-back {
|
||
border: none;
|
||
background: #f0eeeb;
|
||
padding: 8px 14px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
color: #6b6375;
|
||
}
|
||
|
||
.btn-back:hover {
|
||
background: #e5e4e7;
|
||
}
|
||
|
||
.proj-name-input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
font-family: inherit;
|
||
outline: none;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.proj-name-input:focus {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
.ingredients-section,
|
||
.pricing-section,
|
||
.profit-section,
|
||
.notes-section {
|
||
margin-bottom: 20px;
|
||
padding: 14px;
|
||
background: #f8f7f5;
|
||
border-radius: 12px;
|
||
border: 1.5px solid #e5e4e7;
|
||
}
|
||
|
||
.ingredients-section h4,
|
||
.pricing-section h4,
|
||
.profit-section h4,
|
||
.notes-section h4 {
|
||
margin: 0 0 12px;
|
||
font-size: 14px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.section-header-row {
|
||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;
|
||
}
|
||
.section-header-row h4 { margin: 0; }
|
||
.section-actions { display: flex; gap: 6px; }
|
||
|
||
.ingredients-table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||
.ingredients-table th { white-space: nowrap; }
|
||
.ingredients-table th {
|
||
text-align: center; padding: 10px 8px; font-size: 12px; font-weight: 600;
|
||
color: var(--text-light, #999); border-bottom: 2px solid #e5e4e7;
|
||
}
|
||
.ingredients-table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; text-align: center; }
|
||
.ingredients-table .form-select { width: 100%; padding: 6px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; font-family: inherit; background: #fff; }
|
||
.drops-input { width: 65px; padding: 6px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
|
||
.drops-input:focus { border-color: #7ec6a4; }
|
||
.cell-ppd { color: #999; font-size: 12px; }
|
||
.cell-subtotal { color: #4a9d7e; font-weight: 600; }
|
||
.remove-btn { border: none; background: none; color: #ccc; cursor: pointer; font-size: 14px; padding: 2px; }
|
||
.remove-btn:hover { color: #c0392b; }
|
||
|
||
.total-table { background: #e8f5e9; border-radius: 10px; margin-bottom: 0; }
|
||
.total-table td { border: none; padding: 10px 8px; }
|
||
.total-label-cell { font-size: 14px; color: #3e3a44; font-weight: 600; }
|
||
.total-price-cell { font-size: 18px; font-weight: 700; color: #2e7d5a; text-align: center; }
|
||
|
||
.pricing-inline { margin-top: 12px; }
|
||
.price-field { display: flex; align-items: center; gap: 8px; }
|
||
.price-field label { font-size: 13px; font-weight: 600; color: #3e3a44; white-space: nowrap; }
|
||
.price-input { width: 100px; padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 14px; font-family: inherit; outline: none; }
|
||
.price-input:focus { border-color: #7ec6a4; }
|
||
|
||
.form-select {
|
||
flex: 1;
|
||
padding: 8px 10px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
background: #fff;
|
||
}
|
||
|
||
.form-input-sm {
|
||
width: 70px;
|
||
padding: 8px 10px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
outline: none;
|
||
text-align: center;
|
||
}
|
||
|
||
.price-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px 0;
|
||
border-bottom: 1px solid #eae8e5;
|
||
font-size: 13px;
|
||
}
|
||
.price-row .price-label { color: #6b6375; }
|
||
.price-row .price-value { text-align: right; font-weight: 600; }
|
||
.price-val-box { width: 70px; text-align: right; font-weight: 600; color: #4a9d7e; font-size: 13px; }
|
||
.price-row .price-input-wrap { display: flex; align-items: center; gap: 2px; }
|
||
.price-row .form-input-inline, .price-row input[type="number"] { width: 70px; text-align: right; padding: 4px 6px; border: 1px solid #d4cfc7; border-radius: 6px; font-size: 13px; font-family: inherit; outline: none; }
|
||
.price-row .form-input-inline:focus, .price-row input[type="number"]:focus { border-color: #7ec6a4; }
|
||
|
||
.price-row.total {
|
||
border-top: 2px solid #d4cfc7;
|
||
border-bottom: 2px solid #d4cfc7;
|
||
font-weight: 600;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.price-label {
|
||
color: #6b6375;
|
||
}
|
||
|
||
.price-value {
|
||
font-weight: 600;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.price-value.cost {
|
||
color: #4a9d7e;
|
||
}
|
||
|
||
.price-input-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 14px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.form-input-inline {
|
||
width: 80px;
|
||
padding: 6px 8px;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
outline: none;
|
||
text-align: right;
|
||
}
|
||
|
||
.form-input-inline:focus {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
.price-profit-row {
|
||
display: flex; gap: 16px; margin-bottom: 20px;
|
||
}
|
||
.pricing-col, .profit-col {
|
||
flex: 1; padding: 14px; background: #f8f7f5; border-radius: 12px; border: 1.5px solid #e5e4e7;
|
||
}
|
||
.pricing-col h4, .profit-col h4 { margin: 0 0 10px; font-size: 14px; color: #3e3a44; }
|
||
.profit-card {
|
||
padding: 10px 12px; background: #fff; border-radius: 10px;
|
||
border: 1.5px solid #e5e4e7; text-align: center; margin-bottom: 6px;
|
||
}
|
||
.profit-card-label { font-size: 12px; color: #6b6375; margin-bottom: 2px; }
|
||
.profit-card-value { font-size: 18px; font-weight: 700; color: #4a9d7e; }
|
||
.profit-card-value.negative { color: #ef5350; }
|
||
|
||
.notes-textarea {
|
||
width: 100%;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.notes-textarea:focus {
|
||
border-color: #7ec6a4;
|
||
}
|
||
|
||
/* Overlay */
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
}
|
||
|
||
.overlay-panel {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
max-width: 480px;
|
||
width: 100%;
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.overlay-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.overlay-header h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.recipe-import-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.import-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.import-item:hover {
|
||
background: #f0faf5;
|
||
}
|
||
|
||
.import-name {
|
||
flex: 1;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
color: #3e3a44;
|
||
}
|
||
|
||
.import-count {
|
||
font-size: 12px;
|
||
color: #b0aab5;
|
||
}
|
||
|
||
.import-cost {
|
||
font-size: 13px;
|
||
color: #4a9d7e;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 10px;
|
||
padding: 9px 20px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.btn-outline {
|
||
background: #fff;
|
||
color: #6b6375;
|
||
border: 1.5px solid #d4cfc7;
|
||
border-radius: 10px;
|
||
padding: 9px 20px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.btn-outline:hover {
|
||
background: #f8f7f5;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 6px 14px;
|
||
font-size: 12px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.btn-icon-sm {
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
padding: 4px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.btn-close {
|
||
border: none;
|
||
background: #f0eeeb;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #6b6375;
|
||
}
|
||
|
||
.empty-hint {
|
||
text-align: center;
|
||
color: #b0aab5;
|
||
font-size: 13px;
|
||
padding: 24px 0;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.profit-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|