chore: Remove package-lock.json and enhance financials API with user summaries
This commit includes the following changes: - Deleted the `package-lock.json` file to streamline dependency management. - Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits. - Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion. - Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking. - Refactored the costs service to improve group balance summary calculations. These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management.
This commit is contained in:
parent
229f6b7b1c
commit
d6c5e6fcfd
@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from typing import List as PyList, Optional, Sequence, Union
|
||||
from decimal import Decimal
|
||||
|
||||
from app.database import get_transactional_session
|
||||
from app.auth import current_active_user
|
||||
@ -23,7 +24,7 @@ from app.schemas.expense import (
|
||||
SettlementCreate, SettlementPublic,
|
||||
ExpenseUpdate, SettlementUpdate
|
||||
)
|
||||
from app.schemas.financials import FinancialActivityResponse
|
||||
from app.schemas.financials import FinancialActivityResponse, UserFinancialSummary, DebtCredit, SummaryUser
|
||||
from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added
|
||||
from app.crud import expense as crud_expense
|
||||
from app.crud import settlement as crud_settlement
|
||||
@ -35,7 +36,8 @@ from app.core.exceptions import (
|
||||
InvalidOperationError, GroupPermissionError, ListPermissionError,
|
||||
ItemNotFoundError, GroupMembershipError, OverpaymentError, FinancialConflictError
|
||||
)
|
||||
from app.services import financials_service
|
||||
from app.services import financials_service, costs_service
|
||||
from app.schemas.cost import GroupBalanceSummary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -298,7 +300,7 @@ async def delete_expense_record(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User cannot delete this expense (must be payer or group owner)")
|
||||
|
||||
try:
|
||||
await crud_expense.delete_expense(db=db, expense_db=expense_db, expected_version=expected_version)
|
||||
await crud_expense.delete_expense(db=db, expense_db=expense_db, current_user_id=current_user.id, expected_version=expected_version)
|
||||
logger.info(f"Expense ID {expense_id} deleted successfully.")
|
||||
# No need to return content on 204
|
||||
except InvalidOperationError as e:
|
||||
@ -687,4 +689,122 @@ async def get_user_financial_activity(
|
||||
|
||||
# The service returns a mix of ExpenseModel and SettlementModel objects.
|
||||
# We need to wrap it in our response schema. Pydantic will handle the Union type.
|
||||
return FinancialActivityResponse(activities=activities)
|
||||
return FinancialActivityResponse(activities=activities)
|
||||
|
||||
@router.get(
|
||||
"/summary/group/{group_id}",
|
||||
response_model=UserFinancialSummary,
|
||||
summary="Get Financial Summary for a Specific Group (alias)",
|
||||
tags=["Financials", "Groups"],
|
||||
)
|
||||
async def alias_group_balance_summary(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_transactional_session),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Returns a financial summary formatted for the frontend for a single group, leveraging
|
||||
the existing costs_service balance logic."""
|
||||
group_summary = await costs_service.get_group_balance_summary_logic(
|
||||
db=db, group_id=group_id, current_user_id=current_user.id
|
||||
)
|
||||
|
||||
net_balance = Decimal("0.00")
|
||||
debts: PyList[DebtCredit] = []
|
||||
credits: PyList[DebtCredit] = []
|
||||
|
||||
# Find the current user's balance within the group
|
||||
for ub in group_summary.user_balances:
|
||||
if ub.user_id == current_user.id:
|
||||
net_balance = ub.net_balance
|
||||
break
|
||||
|
||||
# Translate suggested settlements
|
||||
if group_summary.suggested_settlements:
|
||||
for s in group_summary.suggested_settlements:
|
||||
if s.from_user_id == current_user.id:
|
||||
debts.append(
|
||||
DebtCredit(
|
||||
user=SummaryUser(id=s.to_user_id, name=s.to_user_identifier),
|
||||
amount=float(s.amount),
|
||||
)
|
||||
)
|
||||
elif s.to_user_id == current_user.id:
|
||||
credits.append(
|
||||
DebtCredit(
|
||||
user=SummaryUser(id=s.from_user_id, name=s.from_user_identifier),
|
||||
amount=float(s.amount),
|
||||
)
|
||||
)
|
||||
|
||||
return UserFinancialSummary(
|
||||
net_balance=float(net_balance.quantize(Decimal("0.01"))),
|
||||
total_group_spending=float(group_summary.overall_total_expenses.quantize(Decimal("0.01"))),
|
||||
debts=debts,
|
||||
credits=credits,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/summary/user",
|
||||
response_model=UserFinancialSummary,
|
||||
summary="Get Consolidated Financial Summary for Current User",
|
||||
tags=["Financials"],
|
||||
)
|
||||
async def get_current_user_financial_summary(
|
||||
db: AsyncSession = Depends(get_transactional_session),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Aggregates the user's financial position across all groups they belong to and returns
|
||||
a concise summary structure expected by the frontend (net balance, debts, credits, etc.)."""
|
||||
# 1. Retrieve groups where the user is a member
|
||||
groups_result = await db.execute(
|
||||
select(GroupModel).join(UserGroupModel).where(UserGroupModel.user_id == current_user.id)
|
||||
)
|
||||
groups: Sequence[GroupModel] = groups_result.scalars().all()
|
||||
|
||||
net_balance = Decimal("0.00")
|
||||
total_group_spending = Decimal("0.00")
|
||||
debts: PyList[DebtCredit] = []
|
||||
credits: PyList[DebtCredit] = []
|
||||
|
||||
# 2. Iterate through each group and accumulate balances & settlements
|
||||
for group in groups:
|
||||
group_summary = await costs_service.get_group_balance_summary_logic(
|
||||
db=db, group_id=group.id, current_user_id=current_user.id
|
||||
)
|
||||
|
||||
total_group_spending += group_summary.overall_total_expenses
|
||||
|
||||
# Find current user's balance within the group
|
||||
for ub in group_summary.user_balances:
|
||||
if ub.user_id == current_user.id:
|
||||
net_balance += ub.net_balance
|
||||
break
|
||||
|
||||
# Translate suggested_settlements into debts/credits relative to current user
|
||||
if group_summary.suggested_settlements:
|
||||
for s in group_summary.suggested_settlements:
|
||||
if s.from_user_id == current_user.id:
|
||||
# Current user owes another household member
|
||||
debts.append(
|
||||
DebtCredit(
|
||||
user=SummaryUser(id=s.to_user_id, name=s.to_user_identifier),
|
||||
amount=float(s.amount),
|
||||
)
|
||||
)
|
||||
elif s.to_user_id == current_user.id:
|
||||
# Another member owes current user
|
||||
credits.append(
|
||||
DebtCredit(
|
||||
user=SummaryUser(id=s.from_user_id, name=s.from_user_identifier),
|
||||
amount=float(s.amount),
|
||||
)
|
||||
)
|
||||
|
||||
return UserFinancialSummary(
|
||||
net_balance=float(net_balance.quantize(Decimal("0.01"))),
|
||||
total_group_spending=float(total_group_spending.quantize(Decimal("0.01"))),
|
||||
debts=debts,
|
||||
credits=credits,
|
||||
currency="USD",
|
||||
)
|
@ -678,6 +678,8 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
|
||||
for k, v in before_state.items():
|
||||
if isinstance(v, (datetime, Decimal)):
|
||||
before_state[k] = str(v)
|
||||
elif hasattr(v, 'value'): # Handle enums
|
||||
before_state[k] = v.value
|
||||
|
||||
update_data = expense_in.dict(exclude_unset=True, exclude={"version"})
|
||||
|
||||
@ -705,6 +707,8 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
|
||||
for k, v in after_state.items():
|
||||
if isinstance(v, (datetime, Decimal)):
|
||||
after_state[k] = str(v)
|
||||
elif hasattr(v, 'value'): # Handle enums
|
||||
after_state[k] = v.value
|
||||
|
||||
await create_financial_audit_log(
|
||||
db=db,
|
||||
@ -740,6 +744,8 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_use
|
||||
for k, v in details.items():
|
||||
if isinstance(v, (datetime, Decimal)):
|
||||
details[k] = str(v)
|
||||
elif hasattr(v, 'value'): # Handle enums
|
||||
details[k] = v.value
|
||||
|
||||
expense_id_for_log = expense_db.id
|
||||
|
||||
@ -751,11 +757,34 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_use
|
||||
details=details
|
||||
)
|
||||
|
||||
# Manually delete related records in correct order to avoid foreign key constraint issues
|
||||
from app.models import ExpenseSplit as ExpenseSplitModel, SettlementActivity as SettlementActivityModel
|
||||
|
||||
# First, get all the splits for this expense
|
||||
splits_result = await db.execute(
|
||||
select(ExpenseSplitModel).where(ExpenseSplitModel.expense_id == expense_db.id)
|
||||
)
|
||||
splits_to_delete = splits_result.scalars().all()
|
||||
|
||||
# Then, delete all settlement activities that reference these splits
|
||||
for split in splits_to_delete:
|
||||
settlement_activities_result = await db.execute(
|
||||
select(SettlementActivityModel).where(SettlementActivityModel.expense_split_id == split.id)
|
||||
)
|
||||
settlement_activities_to_delete = settlement_activities_result.scalars().all()
|
||||
|
||||
for settlement_activity in settlement_activities_to_delete:
|
||||
await db.delete(settlement_activity)
|
||||
|
||||
# Now we can safely delete the splits
|
||||
for split in splits_to_delete:
|
||||
await db.delete(split)
|
||||
|
||||
await db.delete(expense_db)
|
||||
await db.flush()
|
||||
except IntegrityError as e:
|
||||
logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
|
||||
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e
|
||||
raise DatabaseIntegrityError() from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
|
||||
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e
|
||||
|
@ -1,9 +1,38 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Union, List
|
||||
from .expense import ExpensePublic, SettlementPublic
|
||||
from decimal import Decimal
|
||||
|
||||
class FinancialActivityResponse(BaseModel):
|
||||
activities: List[Union[ExpensePublic, SettlementPublic]]
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class SummaryUser(BaseModel):
|
||||
"""Lightweight representation of a user for summary responses."""
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class DebtCredit(BaseModel):
|
||||
"""Represents a debt or credit entry in the user financial summary."""
|
||||
user: SummaryUser
|
||||
amount: float
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class UserFinancialSummary(BaseModel):
|
||||
"""Aggregated financial summary for the authenticated user across all groups."""
|
||||
|
||||
net_balance: float = 0.0
|
||||
total_group_spending: float = 0.0
|
||||
debts: List[DebtCredit] = Field(default_factory=list)
|
||||
credits: List[DebtCredit] = Field(default_factory=list)
|
||||
currency: str = "USD"
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
@ -262,7 +262,7 @@ async def get_group_balance_summary_logic(
|
||||
|
||||
settlements_result = await db.execute(
|
||||
select(SettlementModel).where(SettlementModel.group_id == group_id)
|
||||
.options(selectinload(SettlementModel.paid_by_user), selectinload(SettlementModel.paid_to_user))
|
||||
.options(selectinload(SettlementModel.payer), selectinload(SettlementModel.payee))
|
||||
)
|
||||
settlements = settlements_result.scalars().all()
|
||||
|
||||
@ -330,8 +330,8 @@ async def get_group_balance_summary_logic(
|
||||
|
||||
final_user_balances.sort(key=lambda x: x.user_identifier)
|
||||
suggested_settlements = calculate_suggested_settlements(final_user_balances)
|
||||
overall_total_expenses = sum(expense.total_amount for expense in expenses)
|
||||
overall_total_settlements = sum(settlement.amount for settlement in settlements)
|
||||
overall_total_expenses = sum((expense.total_amount for expense in expenses), start=Decimal("0.00"))
|
||||
overall_total_settlements = sum((settlement.amount for settlement in settlements), start=Decimal("0.00"))
|
||||
|
||||
return GroupBalanceSummary(
|
||||
group_id=db_group.id,
|
||||
|
@ -23,7 +23,12 @@ npm install
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
# Frontend only
|
||||
npm run dev:fe
|
||||
|
||||
# Start both frontend & backend in two terminals
|
||||
npm run dev:be
|
||||
npm run dev:fe
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
337
fe/package-lock.json
generated
337
fe/package-lock.json
generated
@ -12,10 +12,15 @@
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@sentry/tracing": "^7.120.3",
|
||||
"@sentry/vue": "^7.120.3",
|
||||
"@types/dom-speech-recognition": "^0.0.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"mock-socket": "^9.3.1",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qs": "^6.14.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.9.1",
|
||||
@ -4103,6 +4108,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-speech-recognition": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.6.tgz",
|
||||
"integrity": "sha512-o7pAVq9UQPJL5RDjO1f/fcpfFHdgiMnR4PoIU2N/ZQrYOS3C5rzdOJMsrpqeBCbii2EE9mERXgqspQqPDdPahw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
@ -4133,12 +4144,20 @@
|
||||
"version": "22.15.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
|
||||
"integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@ -5041,7 +5060,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@ -5601,6 +5619,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@ -5692,11 +5719,76 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@ -5709,7 +5801,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@ -6030,6 +6121,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
|
||||
@ -6194,6 +6294,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@ -7497,6 +7603,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@ -7860,6 +7975,12 @@
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -8166,7 +8287,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -9154,6 +9274,15 @@
|
||||
"ufo": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mock-socket": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz",
|
||||
"integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@ -9602,6 +9731,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@ -9669,7 +9807,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -9878,6 +10015,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@ -10183,6 +10329,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@ -10449,6 +10612,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@ -10459,6 +10631,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
@ -10787,6 +10965,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@ -12109,7 +12293,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@ -12925,6 +13108,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
@ -13575,6 +13764,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@ -13611,6 +13806,134 @@
|
||||
"url": "https://github.com/sponsors/ota-meshi"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
@ -16,17 +16,24 @@
|
||||
"lint": "run-s lint:*",
|
||||
"format": "prettier --write src/",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"dev:fe": "vite",
|
||||
"dev:be": "cd ../be && uvicorn app.main:app --reload"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@sentry/tracing": "^7.120.3",
|
||||
"@sentry/vue": "^7.120.3",
|
||||
"@types/dom-speech-recognition": "^0.0.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"mock-socket": "^9.3.1",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qs": "^6.14.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.9.1",
|
||||
|
@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<NotificationDisplay /> <!-- For custom notifications -->
|
||||
<NotificationDisplay />
|
||||
<ConflictResolutionDialog />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NotificationDisplay from '@/components/global/NotificationDisplay.vue';
|
||||
import ConflictResolutionDialog from '@/components/global/ConflictResolutionDialog.vue';
|
||||
// Potentially initialize offline store or other global listeners here if needed
|
||||
// import { useOfflineStore } from './stores/offline';
|
||||
// const offlineStore = useOfflineStore();
|
||||
|
@ -1,97 +1,563 @@
|
||||
<template>
|
||||
<TransitionRoot appear :show="open" as="template">
|
||||
<Dialog as="div" class="relative z-50" @close="close">
|
||||
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100"
|
||||
leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
|
||||
<div class="fixed inset-0 bg-black/25" />
|
||||
</TransitionChild>
|
||||
<Dialog v-model="isOpen" :unmount="false">
|
||||
<div class="auth-sheet">
|
||||
<!-- Header -->
|
||||
<div class="auth-header">
|
||||
<div class="brand">
|
||||
<h1 class="brand-title">mitlist</h1>
|
||||
<p class="brand-subtitle">Smart household management</p>
|
||||
</div>
|
||||
|
||||
<div class="fixed inset-0 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
|
||||
enter-to="opacity-100 scale-100" leave="ease-in duration-200" leave-from="opacity-100 scale-100"
|
||||
leave-to="opacity-0 scale-95">
|
||||
<DialogPanel
|
||||
class="w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-dark p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle class="text-lg font-medium leading-6 text-gray-900 dark:text-light mb-4">Sign
|
||||
in</DialogTitle>
|
||||
<button v-if="allowClose" @click="closeSheet" class="close-button" aria-label="Close authentication">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="step === 'email'" class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">Enter your email and we'll send you
|
||||
a magic link.</p>
|
||||
<form @submit.prevent="sendLink" class="space-y-4">
|
||||
<input v-model="email" type="email" placeholder="you@example.com"
|
||||
class="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-primary focus:border-primary text-sm dark:bg-neutral-800 dark:border-neutral-600"
|
||||
required />
|
||||
<button type="submit"
|
||||
class="w-full inline-flex justify-center rounded bg-primary text-white px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
:disabled="loading">
|
||||
<span v-if="loading" class="animate-pulse">Sending…</span>
|
||||
<span v-else>Send Magic Link</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="relative py-2">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-200 dark:border-neutral-700" />
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase"><span
|
||||
class="bg-white dark:bg-dark px-2 text-gray-500">or</span></div>
|
||||
</div>
|
||||
<SocialLoginButtons />
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 'sent'" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-200">We've sent a link to <strong>{{
|
||||
email }}</strong>. Check your inbox and click the link to sign in.</p>
|
||||
<button @click="close" class="mt-2 text-sm text-primary hover:underline">Close</button>
|
||||
</div>
|
||||
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
<!-- Mode Toggle -->
|
||||
<div class="mode-toggle">
|
||||
<div class="mode-buttons">
|
||||
<button :class="['mode-btn', { active: mode === 'login' }]" @click="setMode('login')">
|
||||
Sign In
|
||||
</button>
|
||||
<button :class="['mode-btn', { active: mode === 'register' }]" @click="setMode('register')">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="auth-content">
|
||||
<!-- Welcome Message -->
|
||||
<div class="welcome-section">
|
||||
<h2 class="welcome-title">
|
||||
{{ mode === 'login' ? 'Welcome back!' : 'Welcome to mitlist!' }}
|
||||
</h2>
|
||||
<p class="welcome-text">
|
||||
{{
|
||||
mode === 'login'
|
||||
? 'Sign in to your household dashboard'
|
||||
: 'Join thousands of organized households'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Demo Mode Banner -->
|
||||
<div v-if="showDemoMode" class="demo-banner">
|
||||
<div class="demo-content">
|
||||
<span class="material-icons demo-icon">play_circle</span>
|
||||
<div>
|
||||
<h3 class="demo-title">Try Demo Mode</h3>
|
||||
<p class="demo-text">Explore without creating an account</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="soft" color="primary" size="sm" @click="startDemo">
|
||||
Try Demo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Social Auth (Primary) -->
|
||||
<div class="social-auth-section">
|
||||
<div class="social-buttons">
|
||||
<Button v-for="provider in socialProviders" :key="provider.id" variant="outline" color="neutral"
|
||||
size="lg" fullWidth :loading="authState.loading === provider.id"
|
||||
@click="handleSocialAuth(provider)" class="social-btn">
|
||||
<template #icon-left>
|
||||
<img :src="provider.icon" :alt="provider.name" class="social-icon" />
|
||||
</template>
|
||||
{{ mode === 'login' ? 'Continue' : 'Sign up' }} with {{ provider.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider">
|
||||
<span class="divider-text">or continue with email</span>
|
||||
</div>
|
||||
|
||||
<!-- Email Form -->
|
||||
<form @submit.prevent="handleEmailAuth" class="email-form">
|
||||
<div class="form-field">
|
||||
<Input v-model="authState.email" type="email" placeholder="Enter your email"
|
||||
:error="authState.errors.email" :loading="authState.loading === 'email'"
|
||||
@input="validateEmail" @blur="validateEmail" required autofocus />
|
||||
</div>
|
||||
|
||||
<div v-if="emailMode === 'password'" class="form-field">
|
||||
<Input v-model="authState.password" type="password" placeholder="Enter your password"
|
||||
:error="authState.errors.password" @input="validatePassword" @blur="validatePassword"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'register' && emailMode === 'password'" class="form-field">
|
||||
<Input v-model="authState.name" type="text" placeholder="Your name"
|
||||
:error="authState.errors.name" @input="validateName" @blur="validateName" required />
|
||||
</div>
|
||||
|
||||
<!-- Magic Link Info -->
|
||||
<div v-if="emailMode === 'magic'" class="magic-info">
|
||||
<div class="magic-icon">
|
||||
<span class="material-icons">magic_button</span>
|
||||
</div>
|
||||
<div class="magic-text">
|
||||
<h4>Sign in with magic link</h4>
|
||||
<p>We'll send a secure sign-in link to your email</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button type="submit" variant="solid" color="primary" size="lg" fullWidth
|
||||
:loading="authState.loading === 'email'" :disabled="!isFormValid">
|
||||
{{
|
||||
emailMode === 'magic'
|
||||
? 'Send Magic Link'
|
||||
: mode === 'login'
|
||||
? 'Sign In'
|
||||
: 'Create Account'
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<!-- Auth Mode Toggle -->
|
||||
<div class="auth-toggle">
|
||||
<button type="button" @click="toggleEmailMode" class="toggle-link">
|
||||
{{
|
||||
emailMode === 'magic'
|
||||
? 'Use password instead'
|
||||
: 'Use magic link instead'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="auth-footer">
|
||||
<p class="terms-text">
|
||||
By continuing, you agree to our
|
||||
<a href="/terms" class="terms-link">Terms of Service</a>
|
||||
and
|
||||
<a href="/privacy" class="terms-link">Privacy Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineExpose, watch } from 'vue'
|
||||
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import SocialLoginButtons from '@/components/SocialLoginButtons.vue'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import Dialog from '@/components/ui/Dialog.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
|
||||
const open = ref(false)
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const step = ref<'email' | 'sent'>('email')
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const notify = useNotificationStore()
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
step.value = 'email'
|
||||
email.value = ''
|
||||
}
|
||||
function close() {
|
||||
open.value = false
|
||||
interface SocialProvider {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
url: string
|
||||
}
|
||||
|
||||
async function sendLink() {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.requestMagicLink(email.value)
|
||||
step.value = 'sent'
|
||||
notify.addNotification({ message: 'Magic link sent!', type: 'success' })
|
||||
} catch (e: any) {
|
||||
notify.addNotification({ message: e?.response?.data?.detail || 'Failed to send link', type: 'error' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
interface AuthState {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
loading: string | null
|
||||
errors: {
|
||||
email?: string
|
||||
password?: string
|
||||
name?: string
|
||||
general?: string
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
allowClose?: boolean
|
||||
showDemoMode?: boolean
|
||||
initialMode?: 'login' | 'register'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'demo-started': []
|
||||
'auth-success': [user: any]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const mode = ref<'login' | 'register'>(props.initialMode || 'login')
|
||||
const emailMode = ref<'password' | 'magic'>('magic') // Default to magic link
|
||||
|
||||
const authState = ref<AuthState>({
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
loading: null,
|
||||
errors: {}
|
||||
})
|
||||
|
||||
// Social providers in order of preference
|
||||
const socialProviders = ref<SocialProvider[]>([
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'https://developers.google.com/identity/images/g-logo.png',
|
||||
url: '/api/auth/google'
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
name: 'Apple',
|
||||
icon: 'https://appleid.apple.com/favicon.ico',
|
||||
url: '/api/auth/apple'
|
||||
}
|
||||
])
|
||||
|
||||
// Auto-detect returning users
|
||||
onMounted(() => {
|
||||
const lastEmail = localStorage.getItem('last-email')
|
||||
if (lastEmail) {
|
||||
authState.value.email = lastEmail
|
||||
mode.value = 'login'
|
||||
}
|
||||
})
|
||||
|
||||
const setMode = (newMode: 'login' | 'register') => {
|
||||
mode.value = newMode
|
||||
clearErrors()
|
||||
}
|
||||
|
||||
const closeSheet = () => {
|
||||
if (props.allowClose) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startDemo = () => {
|
||||
emit('demo-started')
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Demo mode started! Explore all features.',
|
||||
})
|
||||
closeSheet()
|
||||
}
|
||||
|
||||
const toggleEmailMode = () => {
|
||||
emailMode.value = emailMode.value === 'magic' ? 'password' : 'magic'
|
||||
clearErrors()
|
||||
}
|
||||
|
||||
const clearErrors = () => {
|
||||
authState.value.errors = {}
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
const validateEmail = () => {
|
||||
const email = authState.value.email
|
||||
if (!email) {
|
||||
authState.value.errors.email = 'Email is required'
|
||||
return false
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
authState.value.errors.email = 'Please enter a valid email'
|
||||
return false
|
||||
}
|
||||
|
||||
delete authState.value.errors.email
|
||||
return true
|
||||
}
|
||||
|
||||
const validatePassword = () => {
|
||||
const password = authState.value.password
|
||||
if (emailMode.value === 'magic') return true
|
||||
|
||||
if (!password) {
|
||||
authState.value.errors.password = 'Password is required'
|
||||
return false
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
authState.value.errors.password = 'Password must be at least 8 characters'
|
||||
return false
|
||||
}
|
||||
|
||||
delete authState.value.errors.password
|
||||
return true
|
||||
}
|
||||
|
||||
const validateName = () => {
|
||||
const name = authState.value.name
|
||||
if (mode.value === 'login' || emailMode.value === 'magic') return true
|
||||
|
||||
if (!name || name.trim().length < 2) {
|
||||
authState.value.errors.name = 'Please enter your name'
|
||||
return false
|
||||
}
|
||||
|
||||
delete authState.value.errors.name
|
||||
return true
|
||||
}
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (emailMode.value === 'magic') {
|
||||
return validateEmail()
|
||||
}
|
||||
|
||||
if (mode.value === 'register') {
|
||||
return validateEmail() && validatePassword() && validateName()
|
||||
}
|
||||
|
||||
return validateEmail() && validatePassword()
|
||||
})
|
||||
|
||||
// Auth handlers
|
||||
const handleSocialAuth = async (provider: SocialProvider) => {
|
||||
authState.value.loading = provider.id
|
||||
try {
|
||||
// In a real app, this would redirect to OAuth provider
|
||||
window.location.href = provider.url
|
||||
} catch (error) {
|
||||
authState.value.errors.general = `Failed to sign in with ${provider.name}`
|
||||
authState.value.loading = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailAuth = async () => {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
authState.value.loading = 'email'
|
||||
clearErrors()
|
||||
|
||||
try {
|
||||
if (emailMode.value === 'magic') {
|
||||
// Send magic link
|
||||
await authStore.sendMagicLink(authState.value.email)
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Magic link sent! Check your email.',
|
||||
})
|
||||
|
||||
closeSheet()
|
||||
} else {
|
||||
// Regular email/password auth
|
||||
if (mode.value === 'login') {
|
||||
const user = await authStore.login({
|
||||
email: authState.value.email,
|
||||
password: authState.value.password
|
||||
})
|
||||
|
||||
localStorage.setItem('last-email', authState.value.email)
|
||||
emit('auth-success', user)
|
||||
closeSheet()
|
||||
} else {
|
||||
const user = await authStore.register({
|
||||
email: authState.value.email,
|
||||
password: authState.value.password,
|
||||
name: authState.value.name
|
||||
})
|
||||
|
||||
localStorage.setItem('last-email', authState.value.email)
|
||||
emit('auth-success', user)
|
||||
closeSheet()
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
authState.value.errors.general = error.message || 'Authentication failed'
|
||||
} finally {
|
||||
authState.value.loading = null
|
||||
}
|
||||
}
|
||||
|
||||
// Clear form when mode changes
|
||||
watch(mode, () => {
|
||||
authState.value.password = ''
|
||||
authState.value.name = ''
|
||||
clearErrors()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.auth-sheet {
|
||||
@apply max-w-md mx-auto bg-white rounded-2xl shadow-floating p-6;
|
||||
@apply max-h-[90vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
@apply flex items-start justify-between mb-6;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@apply text-2xl font-bold text-primary-600 mb-1;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
@apply text-sm text-neutral-600;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply p-2 rounded-full text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100;
|
||||
@apply transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.mode-buttons {
|
||||
@apply flex bg-neutral-100 rounded-lg p-1;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
@apply flex-1 py-2 px-4 text-sm font-medium rounded-md transition-all duration-micro;
|
||||
@apply text-neutral-600 hover:text-neutral-900;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
@apply bg-white text-primary-600 shadow-soft;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
@apply text-center mb-6;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
@apply text-xl font-semibold text-neutral-900 mb-2;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
@apply text-sm text-neutral-600;
|
||||
}
|
||||
|
||||
.demo-banner {
|
||||
@apply flex items-center justify-between p-4 bg-primary-50 rounded-lg border border-primary-200 mb-6;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.demo-icon {
|
||||
@apply text-primary-500 text-xl;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
@apply text-sm font-medium text-primary-900;
|
||||
}
|
||||
|
||||
.demo-text {
|
||||
@apply text-xs text-primary-700;
|
||||
}
|
||||
|
||||
.social-auth-section {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.social-btn {
|
||||
@apply justify-start;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
@apply w-5 h-5;
|
||||
}
|
||||
|
||||
.divider {
|
||||
@apply relative flex items-center justify-center mb-6;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
@apply absolute inset-0 flex items-center;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
@apply border-t border-neutral-200;
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
@apply bg-white px-4 text-xs text-neutral-500 font-medium;
|
||||
}
|
||||
|
||||
.email-form {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.magic-info {
|
||||
@apply flex items-start gap-3 p-4 bg-blue-50 rounded-lg border border-blue-200;
|
||||
}
|
||||
|
||||
.magic-icon {
|
||||
@apply text-blue-500 text-xl;
|
||||
}
|
||||
|
||||
.magic-text h4 {
|
||||
@apply text-sm font-medium text-blue-900 mb-1;
|
||||
}
|
||||
|
||||
.magic-text p {
|
||||
@apply text-xs text-blue-700;
|
||||
}
|
||||
|
||||
.auth-toggle {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.toggle-link {
|
||||
@apply text-sm text-primary-600 hover:text-primary-700 font-medium;
|
||||
@apply transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
@apply mt-6 pt-6 border-t border-neutral-200;
|
||||
}
|
||||
|
||||
.terms-text {
|
||||
@apply text-xs text-neutral-500 text-center;
|
||||
}
|
||||
|
||||
.terms-link {
|
||||
@apply text-primary-600 hover:text-primary-700;
|
||||
@apply transition-colors duration-micro;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
.auth-sheet {
|
||||
@apply m-4 max-h-[95vh];
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for form transitions */
|
||||
.form-field {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
410
fe/src/components/ChoreCard.vue
Normal file
410
fe/src/components/ChoreCard.vue
Normal file
@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<Card class="chore-card" :class="getCardClasses()">
|
||||
<!-- Chore Header -->
|
||||
<div class="chore-header">
|
||||
<div class="chore-info">
|
||||
<div class="chore-priority">
|
||||
<span class="material-icons" :class="getPriorityIconClasses()">
|
||||
{{ getPriorityIcon() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="chore-details">
|
||||
<h3 class="chore-title">{{ chore.name }}</h3>
|
||||
<p v-if="chore.description" class="chore-description">
|
||||
{{ chore.description }}
|
||||
</p>
|
||||
<div class="chore-meta">
|
||||
<span class="chore-due-date" :class="getDueDateClasses()">
|
||||
{{ getDueDateText() }}
|
||||
</span>
|
||||
<div class="chore-points">
|
||||
<span class="material-icons text-warning-500">star</span>
|
||||
<span class="points-text">{{ chore.points }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Info -->
|
||||
<div v-if="chore.assignedTo && !chore.isAvailable" class="assignment-info">
|
||||
<div class="assigned-avatar">
|
||||
<span class="avatar-initials">
|
||||
{{ getAssignedUser()?.name?.charAt(0) || getAssignedUser()?.full_name?.charAt(0) || '?' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator (for recurring chores) -->
|
||||
<div v-if="chore.estimatedDuration" class="progress-section">
|
||||
<div class="progress-info">
|
||||
<span class="material-icons text-neutral-400">schedule</span>
|
||||
<span class="duration-text">{{ formatDuration(chore.estimatedDuration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="chore-actions">
|
||||
<!-- Complete Action (for assigned chores) -->
|
||||
<Button v-if="canCompleteChore()" variant="solid" color="success" size="sm" fullWidth
|
||||
@click="handleComplete" :loading="isCompleting">
|
||||
<template #icon-left>
|
||||
<span class="material-icons">check_circle</span>
|
||||
</template>
|
||||
Mark Complete
|
||||
</Button>
|
||||
|
||||
<!-- Claim Action (for available chores) -->
|
||||
<Button v-else-if="canClaimChore()" variant="outline" color="primary" size="sm" fullWidth
|
||||
@click="handleClaim" :loading="isClaiming">
|
||||
<template #icon-left>
|
||||
<span class="material-icons">volunteer_activism</span>
|
||||
</template>
|
||||
Claim This Chore
|
||||
</Button>
|
||||
|
||||
<!-- View Details Action (for completed/unassigned) -->
|
||||
<Button v-else variant="ghost" color="neutral" size="sm" fullWidth @click="handleViewDetails">
|
||||
<template #icon-left>
|
||||
<span class="material-icons">info</span>
|
||||
</template>
|
||||
{{ category === 'archive' ? 'View Details' : 'Details' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Completion Celebration (for archive) -->
|
||||
<div v-if="category === 'archive' && chore.completedBy" class="completion-info">
|
||||
<div class="completion-badge">
|
||||
<span class="material-icons text-success-500">celebration</span>
|
||||
<span class="completion-text">
|
||||
Completed by {{ getCompletedByUser()?.name || getCompletedByUser()?.full_name || 'Someone' }}
|
||||
</span>
|
||||
<span class="completion-date">
|
||||
{{ formatCompletionDate() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useGroupStore } from '@/stores/groupStore'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import type { UserPublic } from '@/types/user'
|
||||
|
||||
interface Chore {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
dueDate: Date
|
||||
priority: 'urgent' | 'high' | 'medium' | 'low'
|
||||
assignedTo?: string
|
||||
points: number
|
||||
isComplete: boolean
|
||||
completedAt?: Date
|
||||
completedBy?: string
|
||||
isOverdue: boolean
|
||||
isAvailable: boolean
|
||||
estimatedDuration?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
chore: Chore
|
||||
category: 'priority' | 'upcoming' | 'available' | 'archive'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [chore: Chore]
|
||||
claim: [chore: Chore]
|
||||
'view-details': [chore: Chore]
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const isCompleting = ref(false)
|
||||
const isClaiming = ref(false)
|
||||
|
||||
// User lookup functions
|
||||
const getAssignedUser = (): UserPublic | null => {
|
||||
if (!props.chore.assignedTo || !groupStore.currentGroup) return null
|
||||
return groupStore.currentGroup.members.find((member: UserPublic) => member.id.toString() === props.chore.assignedTo) || null
|
||||
}
|
||||
|
||||
const getCompletedByUser = (): UserPublic | null => {
|
||||
if (!props.chore.completedBy || !groupStore.currentGroup) return null
|
||||
return groupStore.currentGroup.members.find((member: UserPublic) => member.id.toString() === props.chore.completedBy) || null
|
||||
}
|
||||
|
||||
// Permission checks
|
||||
const canCompleteChore = (): boolean => {
|
||||
if (props.chore.isComplete) return false
|
||||
if (props.chore.isAvailable) return false
|
||||
if (!props.chore.assignedTo) return false
|
||||
return props.chore.assignedTo === authStore.user?.id
|
||||
}
|
||||
|
||||
const canClaimChore = (): boolean => {
|
||||
if (props.chore.isComplete) return false
|
||||
return props.chore.isAvailable
|
||||
}
|
||||
|
||||
// Styling functions
|
||||
const getCardClasses = () => {
|
||||
const classes = ['transition-all duration-micro hover:shadow-medium']
|
||||
|
||||
if (props.chore.isOverdue) {
|
||||
classes.push('border-error-200 bg-error-50')
|
||||
} else if (props.category === 'priority') {
|
||||
classes.push('border-warning-200 bg-warning-50')
|
||||
} else if (props.category === 'archive') {
|
||||
classes.push('border-success-200 bg-success-50')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
const getPriorityIcon = () => {
|
||||
const icons = {
|
||||
urgent: 'priority_high',
|
||||
high: 'trending_up',
|
||||
medium: 'remove',
|
||||
low: 'trending_down'
|
||||
}
|
||||
return icons[props.chore.priority] || 'remove'
|
||||
}
|
||||
|
||||
const getPriorityIconClasses = () => {
|
||||
const classes = {
|
||||
urgent: 'text-error-500',
|
||||
high: 'text-warning-500',
|
||||
medium: 'text-primary-500',
|
||||
low: 'text-neutral-500'
|
||||
}
|
||||
return classes[props.chore.priority] || 'text-neutral-500'
|
||||
}
|
||||
|
||||
const getDueDateClasses = () => {
|
||||
if (props.chore.isOverdue) {
|
||||
return 'text-error-600 font-medium'
|
||||
} else if (props.category === 'priority') {
|
||||
return 'text-warning-600 font-medium'
|
||||
}
|
||||
return 'text-neutral-600'
|
||||
}
|
||||
|
||||
const getDueDateText = () => {
|
||||
const dueDate = new Date(props.chore.dueDate)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000)
|
||||
|
||||
if (props.chore.isOverdue) {
|
||||
const daysDiff = Math.floor((now.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000))
|
||||
if (daysDiff === 0) return 'Due today'
|
||||
if (daysDiff === 1) return 'Due yesterday'
|
||||
return `Overdue by ${daysDiff} days`
|
||||
}
|
||||
|
||||
if (dueDate.toDateString() === today.toDateString()) {
|
||||
return 'Due today'
|
||||
} else if (dueDate.toDateString() === tomorrow.toDateString()) {
|
||||
return 'Due tomorrow'
|
||||
} else {
|
||||
return `Due ${dueDate.toLocaleDateString()}`
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (minutes: number): string => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}min`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
if (remainingMinutes === 0) {
|
||||
return `${hours}h`
|
||||
}
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
const formatCompletionDate = (): string => {
|
||||
if (!props.chore.completedAt) return ''
|
||||
|
||||
const completedDate = new Date(props.chore.completedAt)
|
||||
const now = new Date()
|
||||
const diffInDays = Math.floor((now.getTime() - completedDate.getTime()) / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (diffInDays === 0) return 'today'
|
||||
if (diffInDays === 1) return 'yesterday'
|
||||
if (diffInDays < 7) return `${diffInDays} days ago`
|
||||
return completedDate.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleComplete = async () => {
|
||||
isCompleting.value = true
|
||||
try {
|
||||
emit('complete', props.chore)
|
||||
} finally {
|
||||
isCompleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClaim = async () => {
|
||||
isClaiming.value = true
|
||||
try {
|
||||
emit('claim', props.chore)
|
||||
} finally {
|
||||
isClaiming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetails = () => {
|
||||
emit('view-details', props.chore)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chore-card {
|
||||
@apply p-4 space-y-4;
|
||||
@apply border border-neutral-200 bg-white rounded-lg;
|
||||
}
|
||||
|
||||
.chore-header {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.chore-info {
|
||||
@apply flex-1 min-w-0 flex gap-3;
|
||||
}
|
||||
|
||||
.chore-priority {
|
||||
@apply flex-shrink-0 mt-1;
|
||||
}
|
||||
|
||||
.chore-details {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.chore-title {
|
||||
@apply text-base font-medium text-neutral-900 mb-1;
|
||||
@apply line-clamp-2;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
@apply text-sm text-neutral-600 mb-2;
|
||||
@apply line-clamp-2;
|
||||
}
|
||||
|
||||
.chore-meta {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
}
|
||||
|
||||
.chore-due-date {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.chore-points {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.points-text {
|
||||
@apply text-xs font-medium text-warning-600;
|
||||
}
|
||||
|
||||
.assignment-info {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.assigned-avatar {
|
||||
@apply w-8 h-8 rounded-full overflow-hidden bg-primary-100;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
@apply text-xs font-medium text-primary-600;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
@apply pt-2 border-t border-neutral-100;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
@apply text-xs text-neutral-600;
|
||||
}
|
||||
|
||||
.chore-actions {
|
||||
@apply pt-2;
|
||||
}
|
||||
|
||||
.completion-info {
|
||||
@apply pt-3 border-t border-success-200;
|
||||
}
|
||||
|
||||
.completion-badge {
|
||||
@apply flex items-center gap-2 text-xs;
|
||||
}
|
||||
|
||||
.completion-text {
|
||||
@apply text-success-700 font-medium;
|
||||
}
|
||||
|
||||
.completion-date {
|
||||
@apply text-success-600;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
.chore-card {
|
||||
@apply p-3 space-y-3;
|
||||
}
|
||||
|
||||
.chore-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.assigned-avatar {
|
||||
@apply w-6 h-6;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation states */
|
||||
.chore-card:hover {
|
||||
@apply scale-[1.02] transform-gpu;
|
||||
}
|
||||
|
||||
.chore-card:active {
|
||||
@apply scale-[0.98] transform-gpu;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chore-card {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
.chore-card:hover,
|
||||
.chore-card:active {
|
||||
@apply transform-none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,61 +1,301 @@
|
||||
<template>
|
||||
<Dialog v-model="isOpen">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-neutral-900 dark:text-neutral-100">
|
||||
{{ chore?.name || 'Chore details' }}
|
||||
</h3>
|
||||
<button @click="close" class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
|
||||
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog v-model="isOpen" class="chore-detail-sheet">
|
||||
<div class="sheet-container">
|
||||
<!-- Enhanced Header -->
|
||||
<div class="sheet-header">
|
||||
<div class="header-content">
|
||||
<div class="header-main">
|
||||
<div class="chore-icon">
|
||||
<BaseIcon :name="getChoreIcon(chore)" class="icon" />
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<Heading :level="2" class="chore-title">
|
||||
{{ chore?.name || 'Chore Details' }}
|
||||
</Heading>
|
||||
<div class="chore-meta">
|
||||
<span class="chore-type">{{ chore?.type === 'group' ? 'Group Task' : 'Personal Task'
|
||||
}}</span>
|
||||
<span class="chore-status" :class="getStatusClass(chore)">
|
||||
{{ getStatusText(chore) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button variant="ghost" size="sm" @click="toggleFavorite" v-if="chore">
|
||||
<BaseIcon :name="isFavorite ? 'heroicons:heart-solid' : 'heroicons:heart-20-solid'"
|
||||
:class="{ 'text-error-500': isFavorite }" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="close" class="close-button">
|
||||
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chore" class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-2">General</h4>
|
||||
<dl class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Type</dt>
|
||||
<dd>{{ chore.type === 'group' ? 'Group' : 'Personal' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Created by</dt>
|
||||
<dd>{{ chore.creator?.name || chore.creator?.email || 'Unknown' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Due date</dt>
|
||||
<dd>{{ formatDate(chore.next_due_date) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Frequency</dt>
|
||||
<dd>{{ frequencyLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<!-- Quick Actions Bar -->
|
||||
<div v-if="chore" class="quick-actions">
|
||||
<Button v-if="canComplete" variant="solid" color="success" size="sm" @click="handleComplete"
|
||||
:loading="isCompleting" class="quick-action">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:check-20-solid" />
|
||||
</template>
|
||||
Complete
|
||||
</Button>
|
||||
<Button v-if="canClaim" variant="solid" color="primary" size="sm" @click="handleClaim"
|
||||
:loading="isClaiming" class="quick-action">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:hand-raised-20-solid" />
|
||||
</template>
|
||||
Claim
|
||||
</Button>
|
||||
<Button variant="outline" color="neutral" size="sm" @click="startTimer" v-if="!isTimerRunning"
|
||||
class="quick-action">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:play-20-solid" />
|
||||
</template>
|
||||
Start Timer
|
||||
</Button>
|
||||
<Button variant="solid" color="warning" size="sm" @click="stopTimer" v-if="isTimerRunning"
|
||||
class="quick-action">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:pause-20-solid" />
|
||||
</template>
|
||||
{{ formatTimerDuration(currentTimerDuration) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chore.description">
|
||||
<h4 class="text-sm font-semibold mb-2">Description</h4>
|
||||
<p class="text-sm text-neutral-700 dark:text-neutral-200 whitespace-pre-wrap">{{ chore.description }}
|
||||
</p>
|
||||
<!-- Content Sections with Progressive Disclosure -->
|
||||
<div class="sheet-content">
|
||||
<!-- Primary Information (Always Visible) -->
|
||||
<Card variant="elevated" color="neutral" padding="lg" class="info-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Overview</h3>
|
||||
<div class="section-badges">
|
||||
<span v-if="chore?.frequency" class="frequency-badge">
|
||||
{{ getFrequencyLabel(chore.frequency) }}
|
||||
</span>
|
||||
<span v-if="getDueDateBadge(chore)" class="due-date-badge" :class="getDueDateClass(chore)">
|
||||
{{ getDueDateBadge(chore) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<div class="overview-item">
|
||||
<div class="item-icon">
|
||||
<BaseIcon name="heroicons:calendar-20-solid" />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<span class="item-label">Due Date</span>
|
||||
<span class="item-value">{{ formatDate(chore?.next_due_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="item-icon">
|
||||
<BaseIcon name="heroicons:user-20-solid" />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<span class="item-label">Created by</span>
|
||||
<span class="item-value">{{ chore?.creator?.full_name || chore?.creator?.email ||
|
||||
'Unknown'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="item-icon">
|
||||
<BaseIcon name="heroicons:arrow-path-20-solid" />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<span class="item-label">Frequency</span>
|
||||
<span class="item-value">{{ getFrequencyDescription(chore) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getCurrentAssignment(chore)" class="overview-item">
|
||||
<div class="item-icon">
|
||||
<BaseIcon name="heroicons:user-circle-20-solid" />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<span class="item-label">Assigned to</span>
|
||||
<span class="item-value">{{ getAssignedUserName(chore) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Description Section (Expandable) -->
|
||||
<Card v-if="chore?.description" variant="outlined" color="neutral" padding="lg"
|
||||
class="description-section">
|
||||
<div class="expandable-section" @click="toggleSection('description')">
|
||||
<div class="section-header-expandable">
|
||||
<div class="section-header-content">
|
||||
<BaseIcon name="heroicons:document-text-20-solid" class="section-icon" />
|
||||
<h3 class="section-title">Description</h3>
|
||||
</div>
|
||||
<BaseIcon
|
||||
:name="expandedSections.description ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
|
||||
class="expand-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<TransitionExpand>
|
||||
<div v-if="expandedSections.description" class="section-content">
|
||||
<p class="description-text">{{ chore.description }}</p>
|
||||
</div>
|
||||
</TransitionExpand>
|
||||
</Card>
|
||||
|
||||
<!-- History & Progress Section (Expandable) -->
|
||||
<Card variant="outlined" color="neutral" padding="lg" class="history-section">
|
||||
<div class="expandable-section" @click="toggleSection('history')">
|
||||
<div class="section-header-expandable">
|
||||
<div class="section-header-content">
|
||||
<BaseIcon name="heroicons:clock-20-solid" class="section-icon" />
|
||||
<h3 class="section-title">History & Progress</h3>
|
||||
<span class="completion-count">{{ getCompletionCount(chore) }} completions</span>
|
||||
</div>
|
||||
<BaseIcon
|
||||
:name="expandedSections.history ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
|
||||
class="expand-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<TransitionExpand>
|
||||
<div v-if="expandedSections.history" class="section-content">
|
||||
<div v-if="chore?.assignments && chore.assignments.length > 0" class="assignments-list">
|
||||
<div v-for="assignment in chore.assignments.slice(0, showAllHistory ? undefined : 3)"
|
||||
:key="assignment.id" class="assignment-item">
|
||||
<div class="assignment-status">
|
||||
<div :class="['status-indicator', { 'completed': assignment.is_complete }]">
|
||||
<BaseIcon
|
||||
:name="assignment.is_complete ? 'heroicons:check-20-solid' : 'heroicons:clock-20-solid'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="assignment-details">
|
||||
<span class="assignment-user">{{ assignment.assigned_user?.full_name ||
|
||||
assignment.assigned_user?.email }}</span>
|
||||
<div class="assignment-dates">
|
||||
<span class="due-date">Due: {{ formatDate(assignment.due_date) }}</span>
|
||||
<span v-if="assignment.completed_at" class="completed-date">
|
||||
Completed: {{ formatDate(assignment.completed_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="chore.assignments.length > 3 && !showAllHistory" variant="ghost"
|
||||
color="primary" size="sm" @click="showAllHistory = true" class="show-more-button">
|
||||
Show {{ chore.assignments.length - 3 }} more assignments
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="no-history">
|
||||
<BaseIcon name="heroicons:calendar-x-mark-20-solid" class="no-history-icon" />
|
||||
<p>No completion history yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionExpand>
|
||||
</Card>
|
||||
|
||||
<!-- Sub-tasks Section (Expandable) -->
|
||||
<Card v-if="chore?.child_chores?.length" variant="outlined" color="neutral" padding="lg"
|
||||
class="subtasks-section">
|
||||
<div class="expandable-section" @click="toggleSection('subtasks')">
|
||||
<div class="section-header-expandable">
|
||||
<div class="section-header-content">
|
||||
<BaseIcon name="heroicons:list-bullet-20-solid" class="section-icon" />
|
||||
<h3 class="section-title">Sub-tasks</h3>
|
||||
<span class="subtask-count">{{ chore.child_chores.length }} tasks</span>
|
||||
</div>
|
||||
<BaseIcon
|
||||
:name="expandedSections.subtasks ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
|
||||
class="expand-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<TransitionExpand>
|
||||
<div v-if="expandedSections.subtasks" class="section-content">
|
||||
<div class="subtasks-list">
|
||||
<div v-for="sub in chore.child_chores" :key="sub.id" class="subtask-item">
|
||||
<div class="subtask-checkbox">
|
||||
<BaseIcon name="heroicons:check-circle-20-solid" v-if="isSubtaskComplete(sub)"
|
||||
class="completed-icon" />
|
||||
<div v-else class="uncompleted-circle"></div>
|
||||
</div>
|
||||
<span class="subtask-name" :class="{ 'completed': isSubtaskComplete(sub) }">
|
||||
{{ sub.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionExpand>
|
||||
</Card>
|
||||
|
||||
<!-- Scheduling Section (Expandable) -->
|
||||
<Card variant="outlined" color="neutral" padding="lg" class="scheduling-section">
|
||||
<div class="expandable-section" @click="toggleSection('scheduling')">
|
||||
<div class="section-header-expandable">
|
||||
<div class="section-header-content">
|
||||
<BaseIcon name="heroicons:calendar-days-20-solid" class="section-icon" />
|
||||
<h3 class="section-title">Scheduling</h3>
|
||||
</div>
|
||||
<BaseIcon
|
||||
:name="expandedSections.scheduling ? 'heroicons:chevron-up-20-solid' : 'heroicons:chevron-down-20-solid'"
|
||||
class="expand-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<TransitionExpand>
|
||||
<div v-if="expandedSections.scheduling" class="section-content">
|
||||
<div class="scheduling-grid">
|
||||
<div class="scheduling-item">
|
||||
<span class="scheduling-label">Next Due Date</span>
|
||||
<span class="scheduling-value">{{ formatDate(chore?.next_due_date) }}</span>
|
||||
</div>
|
||||
<div v-if="chore?.last_completed_at" class="scheduling-item">
|
||||
<span class="scheduling-label">Last Completed</span>
|
||||
<span class="scheduling-value">{{ formatDate(chore.last_completed_at) }}</span>
|
||||
</div>
|
||||
<div class="scheduling-item">
|
||||
<span class="scheduling-label">Created</span>
|
||||
<span class="scheduling-value">{{ formatDate(chore?.created_at) }}</span>
|
||||
</div>
|
||||
<div class="scheduling-item">
|
||||
<span class="scheduling-label">Last Updated</span>
|
||||
<span class="scheduling-value">{{ formatDate(chore?.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionExpand>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="chore.child_chores?.length">
|
||||
<h4 class="text-sm font-semibold mb-2">Sub-Tasks</h4>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="sub in chore.child_chores" :key="sub.id">
|
||||
{{ sub.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Footer Actions -->
|
||||
<div class="sheet-footer">
|
||||
<div class="footer-actions">
|
||||
<Button variant="outline" color="neutral" @click="editChore" v-if="canEdit">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:pencil-20-solid" />
|
||||
</template>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" color="error" @click="deleteChore" v-if="canDelete">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:trash-20-solid" />
|
||||
</template>
|
||||
Delete
|
||||
</Button>
|
||||
<div class="spacer"></div>
|
||||
<Button variant="ghost" color="neutral" @click="close">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { format, isToday, isTomorrow, isPast, isYesterday } from 'date-fns'
|
||||
import type { ChoreWithCompletion } from '@/types/chore'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import Dialog from '@/components/ui/Dialog.vue'
|
||||
import { Dialog, Card, Button, Heading } from '@/components/ui'
|
||||
import TransitionExpand from '@/components/ui/TransitionExpand.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
@ -63,34 +303,559 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'complete', chore: ChoreWithCompletion): void
|
||||
(e: 'claim', chore: ChoreWithCompletion): void
|
||||
(e: 'edit', chore: ChoreWithCompletion): void
|
||||
(e: 'delete', chore: ChoreWithCompletion): void
|
||||
}>()
|
||||
|
||||
// Local proxy for the v-model binding to avoid mutating the prop directly
|
||||
// State
|
||||
const expandedSections = ref({
|
||||
description: false,
|
||||
history: false,
|
||||
subtasks: false,
|
||||
scheduling: false
|
||||
})
|
||||
const showAllHistory = ref(false)
|
||||
const isFavorite = ref(false)
|
||||
const isCompleting = ref(false)
|
||||
const isClaiming = ref(false)
|
||||
const isTimerRunning = ref(false)
|
||||
const currentTimerDuration = ref(0)
|
||||
const timerInterval = ref<number | null>(null)
|
||||
|
||||
// Computed
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: boolean) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const canComplete = computed(() => {
|
||||
if (!props.chore) return false
|
||||
const assignment = getCurrentAssignment(props.chore)
|
||||
return assignment && !assignment.is_complete
|
||||
})
|
||||
|
||||
const canClaim = computed(() => {
|
||||
if (!props.chore) return false
|
||||
return props.chore.type === 'group' && !getCurrentAssignment(props.chore)
|
||||
})
|
||||
|
||||
const canEdit = computed(() => {
|
||||
// Add logic based on user permissions
|
||||
return true
|
||||
})
|
||||
|
||||
const canDelete = computed(() => {
|
||||
// Add logic based on user permissions
|
||||
return true
|
||||
})
|
||||
|
||||
// Methods
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return format(new Date(dateStr), 'PPP')
|
||||
function formatDate(dateStr?: string) {
|
||||
if (!dateStr) return 'Not set'
|
||||
const date = new Date(dateStr)
|
||||
|
||||
if (isToday(date)) return 'Today'
|
||||
if (isTomorrow(date)) return 'Tomorrow'
|
||||
if (isYesterday(date)) return 'Yesterday'
|
||||
|
||||
return format(date, 'MMM d, yyyy')
|
||||
}
|
||||
|
||||
const frequencyLabel = computed(() => {
|
||||
if (!props.chore) return ''
|
||||
const { frequency, custom_interval_days } = props.chore
|
||||
if (frequency === 'custom' && custom_interval_days) {
|
||||
return `Every ${custom_interval_days} days`
|
||||
function getChoreIcon(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore) return 'heroicons:clipboard-document-list-20-solid'
|
||||
|
||||
if (chore.type === 'group') return 'heroicons:user-group-20-solid'
|
||||
return 'heroicons:user-20-solid'
|
||||
}
|
||||
|
||||
function getStatusClass(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore) return ''
|
||||
|
||||
const assignment = getCurrentAssignment(chore)
|
||||
if (assignment?.is_complete) return 'status-completed'
|
||||
|
||||
if (chore.next_due_date && isPast(new Date(chore.next_due_date))) {
|
||||
return 'status-overdue'
|
||||
}
|
||||
|
||||
return 'status-pending'
|
||||
}
|
||||
|
||||
function getStatusText(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore) return 'Unknown'
|
||||
|
||||
const assignment = getCurrentAssignment(chore)
|
||||
if (assignment?.is_complete) return 'Completed'
|
||||
|
||||
if (chore.next_due_date && isPast(new Date(chore.next_due_date))) {
|
||||
return 'Overdue'
|
||||
}
|
||||
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
function getCurrentAssignment(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore?.assignments || chore.assignments.length === 0) return null
|
||||
return chore.assignments[chore.assignments.length - 1]
|
||||
}
|
||||
|
||||
function getAssignedUserName(chore?: ChoreWithCompletion | null) {
|
||||
const assignment = getCurrentAssignment(chore)
|
||||
if (!assignment?.assigned_user) return 'Unassigned'
|
||||
return assignment.assigned_user.full_name || assignment.assigned_user.email
|
||||
}
|
||||
|
||||
function getFrequencyLabel(frequency?: string) {
|
||||
const map: Record<string, string> = {
|
||||
one_time: 'One-time',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
custom: 'Custom'
|
||||
}
|
||||
return map[frequency || ''] || frequency || 'Unknown'
|
||||
}
|
||||
|
||||
function getFrequencyDescription(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore) return 'Unknown'
|
||||
|
||||
const { frequency, custom_interval_days } = chore
|
||||
if (frequency === 'custom' && custom_interval_days) {
|
||||
return `Every ${custom_interval_days} days`
|
||||
}
|
||||
return getFrequencyLabel(frequency)
|
||||
}
|
||||
|
||||
function getDueDateBadge(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore?.next_due_date) return null
|
||||
|
||||
const date = new Date(chore.next_due_date)
|
||||
if (isToday(date)) return 'Due Today'
|
||||
if (isTomorrow(date)) return 'Due Tomorrow'
|
||||
if (isPast(date)) return 'Overdue'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getDueDateClass(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore?.next_due_date) return ''
|
||||
|
||||
const date = new Date(chore.next_due_date)
|
||||
if (isPast(date)) return 'overdue'
|
||||
if (isToday(date)) return 'due-today'
|
||||
if (isTomorrow(date)) return 'due-tomorrow'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function getCompletionCount(chore?: ChoreWithCompletion | null) {
|
||||
if (!chore?.assignments) return 0
|
||||
return chore.assignments.filter(a => a.is_complete).length
|
||||
}
|
||||
|
||||
function isSubtaskComplete(subtask: any) {
|
||||
// This would need to be implemented based on your subtask completion logic
|
||||
return false
|
||||
}
|
||||
|
||||
function toggleSection(section: keyof typeof expandedSections.value) {
|
||||
expandedSections.value[section] = !expandedSections.value[section]
|
||||
}
|
||||
|
||||
function toggleFavorite() {
|
||||
isFavorite.value = !isFavorite.value
|
||||
// Implement favorite logic
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (!props.chore) return
|
||||
|
||||
isCompleting.value = true
|
||||
try {
|
||||
emit('complete', props.chore)
|
||||
} finally {
|
||||
isCompleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClaim() {
|
||||
if (!props.chore) return
|
||||
|
||||
isClaiming.value = true
|
||||
try {
|
||||
emit('claim', props.chore)
|
||||
} finally {
|
||||
isClaiming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editChore() {
|
||||
if (props.chore) {
|
||||
emit('edit', props.chore)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChore() {
|
||||
if (props.chore) {
|
||||
emit('delete', props.chore)
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
isTimerRunning.value = true
|
||||
currentTimerDuration.value = 0
|
||||
timerInterval.value = window.setInterval(() => {
|
||||
currentTimerDuration.value += 1
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
isTimerRunning.value = false
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value)
|
||||
timerInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimerDuration(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Cleanup timer on unmount
|
||||
onUnmounted(() => {
|
||||
if (timerInterval.value) {
|
||||
clearInterval(timerInterval.value)
|
||||
}
|
||||
return map[frequency] || frequency
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chore-detail-sheet {
|
||||
@apply max-w-2xl mx-auto;
|
||||
}
|
||||
|
||||
.sheet-container {
|
||||
@apply flex flex-col h-full max-h-[90vh];
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.sheet-header {
|
||||
@apply space-y-4 p-6 pb-4;
|
||||
@apply border-b border-border-primary;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex items-start justify-between;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
@apply flex items-start gap-4 flex-1;
|
||||
}
|
||||
|
||||
.chore-icon {
|
||||
@apply flex-shrink-0 w-12 h-12;
|
||||
@apply bg-primary-100 dark:bg-primary-900;
|
||||
@apply rounded-lg flex items-center justify-center;
|
||||
}
|
||||
|
||||
.chore-icon .icon {
|
||||
@apply w-6 h-6 text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.chore-title {
|
||||
@apply text-xl font-semibold text-text-primary;
|
||||
@apply leading-tight;
|
||||
}
|
||||
|
||||
.chore-meta {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.chore-type {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.chore-status {
|
||||
@apply text-xs font-medium px-2 py-1 rounded-md;
|
||||
}
|
||||
|
||||
.chore-status.status-completed {
|
||||
@apply bg-success-100 text-success-700 dark:bg-success-900/50 dark:text-success-300;
|
||||
}
|
||||
|
||||
.chore-status.status-overdue {
|
||||
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
|
||||
}
|
||||
|
||||
.chore-status.status-pending {
|
||||
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
@apply flex items-center gap-2 flex-wrap;
|
||||
}
|
||||
|
||||
/* Content Styles */
|
||||
.sheet-content {
|
||||
@apply flex-1 overflow-y-auto p-6 space-y-4;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-lg font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.section-badges {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.frequency-badge {
|
||||
@apply text-xs font-medium px-2 py-1 rounded-md;
|
||||
@apply bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300;
|
||||
}
|
||||
|
||||
.due-date-badge {
|
||||
@apply text-xs font-medium px-2 py-1 rounded-md;
|
||||
}
|
||||
|
||||
.due-date-badge.overdue {
|
||||
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
|
||||
}
|
||||
|
||||
.due-date-badge.due-today {
|
||||
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
|
||||
}
|
||||
|
||||
.due-date-badge.due-tomorrow {
|
||||
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
|
||||
}
|
||||
|
||||
.overview-item {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
@apply flex-shrink-0 w-8 h-8;
|
||||
@apply bg-surface-elevated rounded-lg;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.item-icon svg {
|
||||
@apply w-4 h-4 text-text-secondary;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
@apply text-sm font-medium text-text-primary;
|
||||
}
|
||||
|
||||
/* Expandable Sections */
|
||||
.expandable-section {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.section-header-expandable {
|
||||
@apply flex items-center justify-between py-2;
|
||||
@apply transition-colors duration-micro hover:bg-surface-hover rounded-md;
|
||||
}
|
||||
|
||||
.section-header-content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
@apply w-5 h-5 text-text-secondary;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
@apply w-5 h-5 text-text-secondary;
|
||||
@apply transition-transform duration-micro;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
@apply pt-4;
|
||||
}
|
||||
|
||||
.completion-count,
|
||||
.subtask-count {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
/* Description Section */
|
||||
.description-text {
|
||||
@apply text-sm text-text-primary leading-relaxed;
|
||||
@apply whitespace-pre-wrap;
|
||||
}
|
||||
|
||||
/* History Section */
|
||||
.assignments-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.assignment-item {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.assignment-status {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply w-8 h-8 rounded-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.status-indicator.completed {
|
||||
@apply bg-success-100 text-success-600 dark:bg-success-900/50 dark:text-success-400;
|
||||
}
|
||||
|
||||
.status-indicator:not(.completed) {
|
||||
@apply bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400;
|
||||
}
|
||||
|
||||
.assignment-details {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.assignment-user {
|
||||
@apply text-sm font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.assignment-dates {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.due-date,
|
||||
.completed-date {
|
||||
@apply text-xs text-text-secondary;
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.show-more-button {
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.no-history {
|
||||
@apply text-center py-8 space-y-3;
|
||||
}
|
||||
|
||||
.no-history-icon {
|
||||
@apply w-12 h-12 text-text-secondary mx-auto;
|
||||
}
|
||||
|
||||
/* Subtasks Section */
|
||||
.subtasks-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.subtask-checkbox {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.completed-icon {
|
||||
@apply w-5 h-5 text-success-600 dark:text-success-400;
|
||||
}
|
||||
|
||||
.uncompleted-circle {
|
||||
@apply w-5 h-5 rounded-full border-2 border-neutral-300 dark:border-neutral-600;
|
||||
}
|
||||
|
||||
.subtask-name {
|
||||
@apply text-sm text-text-primary;
|
||||
}
|
||||
|
||||
.subtask-name.completed {
|
||||
@apply line-through text-text-secondary;
|
||||
}
|
||||
|
||||
/* Scheduling Section */
|
||||
.scheduling-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
|
||||
}
|
||||
|
||||
.scheduling-item {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.scheduling-label {
|
||||
@apply text-sm text-text-secondary;
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.scheduling-value {
|
||||
@apply text-sm font-medium text-text-primary;
|
||||
@apply block;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.sheet-footer {
|
||||
@apply p-6 pt-4;
|
||||
@apply border-t border-border-primary;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.header-main {
|
||||
@apply flex-col gap-3;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.scheduling-grid {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
@apply justify-center;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,84 +1,90 @@
|
||||
<template>
|
||||
<li :class="[
|
||||
'relative flex items-start gap-3 py-3 border-b border-neutral-200 dark:border-neutral-700 transition',
|
||||
getDueDateStatus(chore) === 'overdue' && 'bg-warning/10',
|
||||
getDueDateStatus(chore) === 'due-today' && 'bg-success/10',
|
||||
]">
|
||||
<!-- Checkbox + main content -->
|
||||
<label class="flex gap-3 w-full cursor-pointer select-none">
|
||||
<Card as="li" :variant="isOverdue ? 'soft' : 'outlined'" :color="isOverdue ? 'error' : 'neutral'" padding="md"
|
||||
:class="[
|
||||
'chore-item',
|
||||
{ 'is-completed': chore.is_completed, 'is-updating': chore.updating }
|
||||
]">
|
||||
<div class="chore-content">
|
||||
<!-- Checkbox -->
|
||||
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)"
|
||||
class="h-5 w-5 text-primary rounded-md border-neutral-300 dark:border-neutral-600 focus:ring-primary-500 focus:ring-2" />
|
||||
<div class="chore-checkbox">
|
||||
<input type="checkbox" :checked="chore.is_completed" @change.stop="emit('toggle-completion', chore)"
|
||||
class="custom-checkbox" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<!-- Title + badges -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="[chore.is_completed && !chore.updating ? 'line-through text-neutral-400' : '']">
|
||||
{{ chore.name }}
|
||||
</span>
|
||||
|
||||
<template v-if="chore.type === 'group'">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 text-primary text-xs font-medium">
|
||||
Group
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="getDueDateStatus(chore) === 'overdue'">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded bg-danger/10 text-danger text-xs font-medium">
|
||||
Overdue
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="getDueDateStatus(chore) === 'due-today'">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded bg-warning/10 text-warning text-xs font-medium">
|
||||
Due Today
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="getDueDateStatus(chore) === 'upcoming'">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded bg-neutral/10 text-neutral text-xs font-medium">
|
||||
{{ dueInText }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- Main Info -->
|
||||
<div class="chore-details" @click="emit('open-details', chore)">
|
||||
<div class="details-header">
|
||||
<span class="chore-name">{{ chore.name }}</span>
|
||||
<div class="chore-badges">
|
||||
<span v-if="chore.type === 'group'" class="badge type-badge">Group</span>
|
||||
<span v-if="isOverdue" class="badge overdue-badge">Overdue</span>
|
||||
<span v-else-if="isDueToday" class="badge due-today-badge">Due Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="chore.description" class="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-2">
|
||||
<p v-if="chore.description" class="chore-description">
|
||||
{{ chore.description }}
|
||||
</p>
|
||||
|
||||
<!-- Subtext / time -->
|
||||
<span v-if="chore.subtext" class="text-xs text-neutral-500">{{ chore.subtext }}</span>
|
||||
<span v-if="totalTime > 0" class="text-xs text-neutral-500">
|
||||
Total Time: {{ formatDuration(totalTime) }}
|
||||
</span>
|
||||
<div class="details-footer">
|
||||
<span v-if="dueInText" class="due-date-text">
|
||||
<BaseIcon name="heroicons:calendar-20-solid" />
|
||||
<span>{{ dueInText }}</span>
|
||||
</span>
|
||||
<span v-if="totalTime > 0" class="time-tracked-text">
|
||||
<BaseIcon name="heroicons:clock-20-solid" />
|
||||
<span>{{ formatDuration(totalTime) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<Button variant="ghost" size="sm" color="neutral" @click="toggleTimer"
|
||||
:disabled="chore.is_completed || !chore.current_assignment_id">
|
||||
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'"
|
||||
class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-details', chore)">
|
||||
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-history', chore)">
|
||||
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" color="neutral" @click="emit('edit', chore)">
|
||||
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" color="danger" @click="emit('delete', chore)">
|
||||
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
|
||||
</Button>
|
||||
<!-- Action Menu -->
|
||||
<div class="chore-actions">
|
||||
<Menu as="div" class="relative">
|
||||
<MenuButton as="template">
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<BaseIcon name="heroicons:ellipsis-vertical-20-solid" />
|
||||
</Button>
|
||||
</MenuButton>
|
||||
<transition enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0">
|
||||
<MenuItems class="action-menu">
|
||||
<MenuItem v-if="!chore.is_completed" v-slot="{ active }">
|
||||
<button :class="['menu-item', { 'active': active }]" @click.stop="toggleTimer">
|
||||
<BaseIcon
|
||||
:name="isActiveTimer ? 'heroicons:pause-circle-20-solid' : 'heroicons:play-circle-20-solid'" />
|
||||
<span>{{ isActiveTimer ? 'Stop Timer' : 'Start Timer' }}</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button :class="['menu-item', { 'active': active }]"
|
||||
@click.stop="emit('open-history', chore)">
|
||||
<BaseIcon name="heroicons:clock-20-solid" />
|
||||
<span>View History</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button :class="['menu-item', { 'active': active }]" @click.stop="emit('edit', chore)">
|
||||
<BaseIcon name="heroicons:pencil-20-solid" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button :class="['menu-item', 'danger', { 'active': active }]"
|
||||
@click.stop="emit('delete', chore)">
|
||||
<BaseIcon name="heroicons:trash-20-solid" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recursive children -->
|
||||
<ul v-if="chore.child_chores?.length" class="ml-5">
|
||||
<ul v-if="chore.child_chores?.length" class="child-chores-list">
|
||||
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
|
||||
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
|
||||
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
|
||||
@ -86,17 +92,18 @@
|
||||
@start-timer="emit('start-timer', $event)"
|
||||
@stop-timer="(chore, timeEntryId) => emit('stop-timer', chore, timeEntryId)" />
|
||||
</ul>
|
||||
</li>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits, computed } from 'vue';
|
||||
import { formatDistanceToNow, isToday } from 'date-fns';
|
||||
import type { ChoreWithCompletion } from '../types/chore';
|
||||
import { formatDistanceToNow, isToday, isPast } from 'date-fns';
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
import type { ChoreWithCompletion } from '@/types/chore';
|
||||
import type { TimeEntry } from '@/types/time_entry';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { formatDuration } from '@/utils/formatters';
|
||||
import BaseIcon from './BaseIcon.vue';
|
||||
import { Button } from '@/components/ui';
|
||||
import { Button, Card } from '@/components/ui';
|
||||
|
||||
// --- props & emits ---
|
||||
const props = defineProps<{
|
||||
@ -131,6 +138,9 @@ const dueInText = computed(() => {
|
||||
return formatDistanceToNow(dueDate, { addSuffix: true });
|
||||
});
|
||||
|
||||
const isOverdue = computed(() => getDueDateStatus(props.chore) === 'overdue');
|
||||
const isDueToday = computed(() => getDueDateStatus(props.chore) === 'due-today');
|
||||
|
||||
// --- methods ---
|
||||
function toggleTimer() {
|
||||
if (isActiveTimer.value) {
|
||||
@ -143,14 +153,16 @@ function toggleTimer() {
|
||||
function getDueDateStatus(chore: ChoreWithCompletion) {
|
||||
if (chore.is_completed) return 'completed';
|
||||
|
||||
if (!chore.next_due_date) return 'none';
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const dueDate = new Date(chore.next_due_date);
|
||||
dueDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dueDate < today) return 'overdue';
|
||||
if (dueDate.getTime() === today.getTime()) return 'due-today';
|
||||
if (isPast(dueDate) && !isToday(dueDate)) return 'overdue';
|
||||
if (isToday(dueDate)) return 'due-today';
|
||||
return 'upcoming';
|
||||
}
|
||||
</script>
|
||||
@ -159,4 +171,117 @@ function getDueDateStatus(chore: ChoreWithCompletion) {
|
||||
export default {
|
||||
name: 'ChoreItem'
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chore-item {
|
||||
@apply transition-all duration-micro ease-micro;
|
||||
}
|
||||
|
||||
.chore-item.is-updating {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
.chore-content {
|
||||
@apply flex items-start gap-4;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.chore-checkbox {
|
||||
@apply pt-0.5;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
@apply h-5 w-5 rounded;
|
||||
@apply text-primary-600 bg-surface-primary border-border-secondary;
|
||||
@apply focus:ring-primary-500 focus:ring-offset-surface-primary;
|
||||
@apply transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.is-completed .custom-checkbox {
|
||||
@apply border-transparent bg-neutral-300 dark:bg-neutral-600;
|
||||
}
|
||||
|
||||
/* Details */
|
||||
.chore-details {
|
||||
@apply flex-1 cursor-pointer;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
@apply flex items-center gap-2 flex-wrap;
|
||||
}
|
||||
|
||||
.chore-name {
|
||||
@apply font-semibold text-text-primary;
|
||||
@apply transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.is-completed .chore-name {
|
||||
@apply line-through text-text-tertiary;
|
||||
}
|
||||
|
||||
.chore-badges {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply text-xs font-medium px-2 py-0.5 rounded-full;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.overdue-badge {
|
||||
@apply bg-error-100 text-error-700 dark:bg-error-900/50 dark:text-error-300;
|
||||
}
|
||||
|
||||
.due-today-badge {
|
||||
@apply bg-warning-100 text-warning-700 dark:bg-warning-900/50 dark:text-warning-300;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
@apply text-sm text-text-secondary mt-1 line-clamp-2;
|
||||
}
|
||||
|
||||
.details-footer {
|
||||
@apply flex items-center gap-4 text-xs text-text-secondary mt-2;
|
||||
}
|
||||
|
||||
.due-date-text,
|
||||
.time-tracked-text {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.chore-actions {
|
||||
@apply relative z-10;
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
@apply absolute right-0 mt-2 w-48 origin-top-right;
|
||||
@apply bg-surface-elevated rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
@apply flex items-center w-full px-3 py-2 text-sm gap-3;
|
||||
@apply text-text-primary;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
@apply bg-surface-hover;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
@apply text-error-600 dark:text-error-400;
|
||||
}
|
||||
|
||||
.menu-item.danger.active {
|
||||
@apply bg-error-50 dark:bg-error-900/50;
|
||||
}
|
||||
|
||||
/* Children */
|
||||
.child-chores-list {
|
||||
@apply mt-4 pl-6 border-l-2 border-border-secondary;
|
||||
}
|
||||
</style>
|
560
fe/src/components/ChoresList.vue
Normal file
560
fe/src/components/ChoresList.vue
Normal file
@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<div class="chores-list-container">
|
||||
<!-- Header -->
|
||||
<div class="chores-header">
|
||||
<Heading size="xl" class="text-neutral-900">Chores</Heading>
|
||||
<div class="header-actions">
|
||||
<Button variant="soft" color="primary" size="sm" @click="handleAddChore">
|
||||
<template #icon-left>
|
||||
<span class="material-icons text-lg">add_task</span>
|
||||
</template>
|
||||
Add Chore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chore Categories Tabs -->
|
||||
<TabGroup @change="handleTabChange" v-slot="{ selectedIndex }">
|
||||
<TabList class="chore-tabs">
|
||||
<Tab v-for="(category, index) in choreCategories" :key="category.id" v-slot="{ selected }"
|
||||
class="chore-tab" :class="getTabClasses(category, selected)">
|
||||
<div class="tab-content">
|
||||
<span class="tab-icon" :class="getTabIconClasses(category)">
|
||||
<span class="material-icons">{{ category.icon }}</span>
|
||||
</span>
|
||||
<div class="tab-info">
|
||||
<span class="tab-title">{{ category.title }}</span>
|
||||
<span v-if="category.count > 0" class="tab-count" :class="getTabCountClasses(category)">
|
||||
{{ category.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels class="chore-panels">
|
||||
<TabPanel v-for="category in choreCategories" :key="category.id" class="chore-panel">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div v-for="n in 3" :key="n" class="chore-skeleton">
|
||||
<div class="skeleton-icon animate-skeleton"></div>
|
||||
<div class="skeleton-content">
|
||||
<div class="skeleton-title animate-skeleton"></div>
|
||||
<div class="skeleton-subtitle animate-skeleton"></div>
|
||||
</div>
|
||||
<div class="skeleton-action animate-skeleton"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="getFilteredChores(category.id).length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<span class="material-icons">{{ getEmptyStateIcon(category.id) }}</span>
|
||||
</div>
|
||||
<h3 class="empty-title">{{ getEmptyStateTitle(category.id) }}</h3>
|
||||
<p class="empty-description">{{ getEmptyStateDescription(category.id) }}</p>
|
||||
<Button v-if="category.id !== 'archive'" variant="soft" color="primary" size="sm"
|
||||
@click="handleAddChore">
|
||||
{{ getEmptyStateAction(category.id) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Chores List -->
|
||||
<div v-else class="chores-grid">
|
||||
<TransitionGroup name="chore-list" tag="div" class="chores-container">
|
||||
<ChoreCard v-for="chore in getFilteredChores(category.id)" :key="chore.id" :chore="chore"
|
||||
:category="category.id" @complete="handleCompleteChore" @claim="handleClaimChore"
|
||||
@view-details="handleViewDetails" class="chore-card-item" />
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
|
||||
import { useChoreStore } from '@/stores/choreStore'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import Heading from '@/components/ui/Heading.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import ChoreCard from '@/components/ChoreCard.vue'
|
||||
|
||||
interface ChoreCategory {
|
||||
id: 'priority' | 'upcoming' | 'available' | 'archive'
|
||||
title: string
|
||||
icon: string
|
||||
count: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface Chore {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
dueDate: Date
|
||||
priority: 'urgent' | 'high' | 'medium' | 'low'
|
||||
assignedTo?: string
|
||||
points: number
|
||||
isComplete: boolean
|
||||
completedAt?: Date
|
||||
completedBy?: string
|
||||
isOverdue: boolean
|
||||
isAvailable: boolean
|
||||
estimatedDuration?: number
|
||||
}
|
||||
|
||||
const choreStore = useChoreStore()
|
||||
const authStore = useAuthStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const selectedCategory = ref<string>('priority')
|
||||
|
||||
// Computed chore categories with dynamic counts
|
||||
const choreCategories = computed<ChoreCategory[]>(() => {
|
||||
const allChores = choreStore.allChores || []
|
||||
const userId = authStore.user?.id
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'priority',
|
||||
title: 'Priority',
|
||||
icon: 'priority_high',
|
||||
count: getPriorityChores().length,
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
id: 'upcoming',
|
||||
title: 'Upcoming',
|
||||
icon: 'schedule',
|
||||
count: getUpcomingChores().length,
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
id: 'available',
|
||||
title: 'Available',
|
||||
icon: 'volunteer_activism',
|
||||
count: getAvailableChores().length,
|
||||
priority: 3
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
title: 'Archive',
|
||||
icon: 'archive',
|
||||
count: getArchivedChores().length,
|
||||
priority: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Filter functions for each category
|
||||
const getPriorityChores = (): Chore[] => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
return choreStore.allChores
|
||||
.filter(chore => !chore.isComplete)
|
||||
.filter(chore => {
|
||||
const dueDate = new Date(chore.dueDate)
|
||||
return dueDate <= today || chore.isOverdue
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by: overdue first, then by due date
|
||||
if (a.isOverdue && !b.isOverdue) return -1
|
||||
if (!a.isOverdue && b.isOverdue) return 1
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
||||
})
|
||||
}
|
||||
|
||||
const getUpcomingChores = (): Chore[] => {
|
||||
const now = new Date()
|
||||
const weekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
return choreStore.allChores
|
||||
.filter(chore => !chore.isComplete && !chore.isOverdue)
|
||||
.filter(chore => {
|
||||
const dueDate = new Date(chore.dueDate)
|
||||
return dueDate > now && dueDate <= weekFromNow
|
||||
})
|
||||
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())
|
||||
}
|
||||
|
||||
const getAvailableChores = (): Chore[] => {
|
||||
return choreStore.allChores
|
||||
.filter(chore => !chore.isComplete && chore.isAvailable)
|
||||
.sort((a, b) => b.points - a.points) // Sort by points descending
|
||||
}
|
||||
|
||||
const getArchivedChores = (): Chore[] => {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
return choreStore.allChores
|
||||
.filter(chore => chore.isComplete && chore.completedAt && new Date(chore.completedAt) >= sevenDaysAgo)
|
||||
.sort((a, b) => new Date(b.completedAt!).getTime() - new Date(a.completedAt!).getTime())
|
||||
}
|
||||
|
||||
const getFilteredChores = (categoryId: string): Chore[] => {
|
||||
switch (categoryId) {
|
||||
case 'priority':
|
||||
return getPriorityChores()
|
||||
case 'upcoming':
|
||||
return getUpcomingChores()
|
||||
case 'available':
|
||||
return getAvailableChores()
|
||||
case 'archive':
|
||||
return getArchivedChores()
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Tab styling functions
|
||||
const getTabClasses = (category: ChoreCategory, selected: boolean) => {
|
||||
const baseClasses = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50'
|
||||
|
||||
if (selected) {
|
||||
if (category.id === 'priority' && category.count > 0) {
|
||||
return `${baseClasses} tab-selected tab-priority-active`
|
||||
}
|
||||
return `${baseClasses} tab-selected`
|
||||
}
|
||||
|
||||
return `${baseClasses} tab-unselected`
|
||||
}
|
||||
|
||||
const getTabIconClasses = (category: ChoreCategory) => {
|
||||
if (category.id === 'priority' && category.count > 0) {
|
||||
return 'tab-icon-priority'
|
||||
}
|
||||
return 'tab-icon-default'
|
||||
}
|
||||
|
||||
const getTabCountClasses = (category: ChoreCategory) => {
|
||||
if (category.id === 'priority' && category.count > 0) {
|
||||
return 'tab-count-priority'
|
||||
}
|
||||
return 'tab-count-default'
|
||||
}
|
||||
|
||||
// Empty state functions
|
||||
const getEmptyStateIcon = (categoryId: string) => {
|
||||
const icons = {
|
||||
priority: 'check_circle',
|
||||
upcoming: 'event_available',
|
||||
available: 'volunteer_activism',
|
||||
archive: 'archive'
|
||||
}
|
||||
return icons[categoryId as keyof typeof icons] || 'check_circle'
|
||||
}
|
||||
|
||||
const getEmptyStateTitle = (categoryId: string) => {
|
||||
const titles = {
|
||||
priority: 'All caught up!',
|
||||
upcoming: 'Nothing this week',
|
||||
available: 'No available chores',
|
||||
archive: 'No recent completions'
|
||||
}
|
||||
return titles[categoryId as keyof typeof titles] || 'Nothing here'
|
||||
}
|
||||
|
||||
const getEmptyStateDescription = (categoryId: string) => {
|
||||
const descriptions = {
|
||||
priority: 'No urgent or overdue tasks. Great work!',
|
||||
upcoming: 'No chores scheduled for this week.',
|
||||
available: 'All chores are assigned or completed.',
|
||||
archive: 'Complete some chores to see them here.'
|
||||
}
|
||||
return descriptions[categoryId as keyof typeof descriptions] || ''
|
||||
}
|
||||
|
||||
const getEmptyStateAction = (categoryId: string) => {
|
||||
const actions = {
|
||||
priority: 'Add New Chore',
|
||||
upcoming: 'Schedule Chore',
|
||||
available: 'Create Available Chore',
|
||||
archive: 'View All Chores'
|
||||
}
|
||||
return actions[categoryId as keyof typeof actions] || 'Add Chore'
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleTabChange = (index: number) => {
|
||||
const category = choreCategories.value[index]
|
||||
selectedCategory.value = category.id
|
||||
}
|
||||
|
||||
const handleAddChore = () => {
|
||||
// Emit event or navigate to chore creation
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: 'Chore creation coming soon!',
|
||||
})
|
||||
}
|
||||
|
||||
const handleCompleteChore = async (chore: Chore) => {
|
||||
try {
|
||||
await choreStore.completeChore(chore.id)
|
||||
|
||||
// Add celebration notification
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `🎉 "${chore.name}" completed! +${chore.points} points`,
|
||||
})
|
||||
|
||||
// Haptic feedback if available
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate([100, 50, 100])
|
||||
}
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to complete chore. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleClaimChore = async (chore: Chore) => {
|
||||
try {
|
||||
await choreStore.claimChore(chore.id, authStore.user!.id)
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `You claimed "${chore.name}"`,
|
||||
})
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to claim chore. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetails = (chore: Chore) => {
|
||||
// Navigate to chore details or open modal
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: 'Chore details coming soon!',
|
||||
})
|
||||
}
|
||||
|
||||
// Load chores on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await choreStore.fetchChores()
|
||||
} catch (error) {
|
||||
console.error('Failed to load chores:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chores-list-container {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.chores-header {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
/* Tab Styles */
|
||||
.chore-tabs {
|
||||
@apply flex bg-neutral-100 rounded-xl p-1 mb-6;
|
||||
@apply overflow-x-auto scrollbar-hide;
|
||||
}
|
||||
|
||||
.chore-tab {
|
||||
@apply flex-1 min-w-0 px-4 py-3 rounded-lg transition-all duration-micro;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.tab-selected {
|
||||
@apply bg-white shadow-soft;
|
||||
}
|
||||
|
||||
.tab-unselected {
|
||||
@apply hover:bg-white/50;
|
||||
}
|
||||
|
||||
.tab-priority-active {
|
||||
@apply bg-error-50 border border-error-200;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.tab-icon-default {
|
||||
@apply text-neutral-600;
|
||||
}
|
||||
|
||||
.tab-icon-priority {
|
||||
@apply text-error-500;
|
||||
}
|
||||
|
||||
.tab-info {
|
||||
@apply flex-1 min-w-0 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
@apply text-sm font-medium text-neutral-900 truncate;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
@apply text-xs px-2 py-1 rounded-full font-medium;
|
||||
}
|
||||
|
||||
.tab-count-default {
|
||||
@apply bg-neutral-200 text-neutral-700;
|
||||
}
|
||||
|
||||
.tab-count-priority {
|
||||
@apply bg-error-100 text-error-700;
|
||||
}
|
||||
|
||||
/* Panel Styles */
|
||||
.chore-panels {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.chore-panel {
|
||||
@apply focus:outline-none;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.chore-skeleton {
|
||||
@apply flex items-center gap-4 p-4 bg-white rounded-lg border border-neutral-200;
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
@apply w-10 h-10 bg-neutral-200 rounded-full;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
@apply flex-1 space-y-2;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
@apply h-4 bg-neutral-200 rounded w-3/4;
|
||||
}
|
||||
|
||||
.skeleton-subtitle {
|
||||
@apply h-3 bg-neutral-200 rounded w-1/2;
|
||||
}
|
||||
|
||||
.skeleton-action {
|
||||
@apply w-20 h-8 bg-neutral-200 rounded;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
@apply text-center py-12 px-4;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply text-neutral-400 text-5xl mb-4;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@apply text-lg font-medium text-neutral-900 mb-2;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
@apply text-sm text-neutral-600 mb-6 max-w-md mx-auto;
|
||||
}
|
||||
|
||||
/* Chores Grid */
|
||||
.chores-grid {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.chores-container {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.chore-card-item {
|
||||
@apply transition-all duration-micro;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.chore-list-enter-active,
|
||||
.chore-list-leave-active {
|
||||
transition: all 300ms ease-page;
|
||||
}
|
||||
|
||||
.chore-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
|
||||
.chore-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px) scale(0.95);
|
||||
}
|
||||
|
||||
.chore-list-move {
|
||||
transition: transform 300ms ease-page;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 640px) {
|
||||
.chore-tabs {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@apply gap-2;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
@apply text-xs px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply py-8;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply text-4xl mb-3;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.chore-list-enter-active,
|
||||
.chore-list-leave-active,
|
||||
.chore-list-move {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.chore-card-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,84 +1,252 @@
|
||||
<template>
|
||||
<section>
|
||||
<Heading :level="3" class="mb-4">{{ t('inviteManager.title', 'Household Invites') }}</Heading>
|
||||
<div class="invite-manager">
|
||||
<div v-if="invite" class="active-invite-section">
|
||||
<p class="section-description">Share this code or QR with new members to let them join.</p>
|
||||
|
||||
<!-- Generate / regenerate button -->
|
||||
<Button :disabled="generating" @click="generateInvite" class="mb-3">
|
||||
<Spinner v-if="generating" class="mr-2" size="sm" />
|
||||
{{ inviteCode ? t('inviteManager.regenerate', 'Regenerate Invite Code') : t('inviteManager.generate',
|
||||
'Generate Invite Code') }}
|
||||
</Button>
|
||||
<div class="invite-display">
|
||||
<div class="qr-code-wrapper">
|
||||
<div v-if="qrCodeUrl" class="qr-code">
|
||||
<img :src="qrCodeUrl" alt="Household Invite QR Code" />
|
||||
</div>
|
||||
<div v-else class="qr-code-placeholder">
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="invite-code-wrapper">
|
||||
<span class="invite-code">{{ formattedInviteCode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active invite display -->
|
||||
<div v-if="inviteCode" class="mb-2 flex items-center gap-2">
|
||||
<Input :model-value="inviteCode" readonly class="flex-1" />
|
||||
<Button variant="outline" color="secondary" size="sm" :disabled="!clipboardSupported" @click="copyInvite">
|
||||
{{ copied ? t('inviteManager.copied', 'Copied!') : t('inviteManager.copy', 'Copy') }}
|
||||
<div class="invite-actions">
|
||||
<Button variant="outline" size="sm" @click="copyInvite" :disabled="!clipboardSupported">
|
||||
<BaseIcon :name="copied ? 'heroicons:check-20-solid' : 'heroicons:clipboard-document'" />
|
||||
<span>{{ copied ? 'Copied!' : 'Copy Code' }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="copyDeepLink">
|
||||
<BaseIcon name="heroicons:link" />
|
||||
<span>Copy Deep Link</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="shareInvite">
|
||||
<BaseIcon name="heroicons:share" />
|
||||
<span>Share Link</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="invite-footer">
|
||||
<p class="expiry-info">Expires: {{ expiryDate }}</p>
|
||||
<Button variant="ghost" color="error" size="sm" @click="revokeInvite" :loading="revoking">
|
||||
Revoke Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-invite-section">
|
||||
<p class="section-description">Generate an invite to allow new members to join your household.</p>
|
||||
<Button @click="generateInvite" :loading="generating" full-width>
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:plus-circle-20-solid" />
|
||||
</template>
|
||||
Generate Invite Code
|
||||
</Button>
|
||||
</div>
|
||||
<Alert v-if="error" type="error" :message="error" />
|
||||
</section>
|
||||
|
||||
<Alert v-if="error" type="error" :message="error" class="mt-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useClipboard, useShare } from '@vueuse/core'
|
||||
import QRCode from 'qrcode'
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Heading from '@/components/ui/Heading.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Spinner from '@/components/ui/Spinner.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import { Button, Spinner, Alert } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { track } from '@/utils/analytics'
|
||||
|
||||
interface Props {
|
||||
groupId: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
interface Invite {
|
||||
code: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const inviteCode = ref<string | null>(null)
|
||||
const invite = ref<Invite | null>(null)
|
||||
const generating = ref(false)
|
||||
const revoking = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const qrCodeUrl = ref<string | null>(null)
|
||||
|
||||
const { copy, copied, isSupported: clipboardSupported } = useClipboard({ legacy: true })
|
||||
const { copy, copied, isSupported: clipboardSupported } = useClipboard({ legacy: true, copiedDuring: 1500 })
|
||||
const { share, isSupported: shareSupported } = useShare()
|
||||
|
||||
const deepLinkScheme = 'myapp://join/'
|
||||
const inviteLink = computed(() => `${window.location.origin}/join?invite_code=${invite.value?.code}`)
|
||||
const deepLink = computed(() => `${deepLinkScheme}${invite.value?.code || ''}`)
|
||||
|
||||
const formattedInviteCode = computed(() => {
|
||||
const code = invite.value?.code || ''
|
||||
return code.match(/.{1,4}/g)?.join('-') || code
|
||||
})
|
||||
|
||||
const expiryDate = computed(() => {
|
||||
if (!invite.value) return ''
|
||||
return formatDistanceToNow(parseISO(invite.value.expires_at), { addSuffix: true })
|
||||
})
|
||||
|
||||
async function generateQrCode() {
|
||||
if (!invite.value) {
|
||||
qrCodeUrl.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
qrCodeUrl.value = await QRCode.toDataURL(inviteLink.value, {
|
||||
errorCorrectionLevel: 'H',
|
||||
margin: 2,
|
||||
width: 160,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to generate QR code', err)
|
||||
qrCodeUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActiveInvite() {
|
||||
if (!props.groupId) return
|
||||
try {
|
||||
const res = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(props.groupId)))
|
||||
if (res.data && res.data.code) {
|
||||
inviteCode.value = res.data.code
|
||||
}
|
||||
const { data } = await apiClient.get<Invite>(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(props.groupId)))
|
||||
invite.value = data
|
||||
} catch (err: any) {
|
||||
// silent – absence of active invite is OK
|
||||
invite.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
if (!props.groupId) return
|
||||
generating.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(props.groupId)))
|
||||
inviteCode.value = res.data.code
|
||||
const { data } = await apiClient.post<Invite>(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(props.groupId)))
|
||||
invite.value = data
|
||||
notifications.addNotification({ type: 'success', message: 'New invite generated.' })
|
||||
track('invite_generated', { groupId: props.groupId })
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || err.message || t('inviteManager.generateError', 'Failed to generate invite')
|
||||
error.value = err?.response?.data?.detail || 'Failed to generate invite'
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!inviteCode.value) return
|
||||
await copy(inviteCode.value)
|
||||
async function revokeInvite() {
|
||||
if (!invite.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.GROUPS.REVOKE_INVITE(String(props.groupId), invite.value.code))
|
||||
notifications.addNotification({ type: 'success', message: 'Invite has been revoked.' })
|
||||
invite.value = null
|
||||
track('invite_revoked', { groupId: props.groupId })
|
||||
} catch (err) {
|
||||
notifications.addNotification({ type: 'error', message: 'Failed to revoke invite.' })
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyInvite() {
|
||||
if (!invite.value) return
|
||||
copy(invite.value.code)
|
||||
notifications.addNotification({ type: 'info', message: 'Invite code copied to clipboard.' })
|
||||
track('invite_code_copied', { groupId: props.groupId })
|
||||
}
|
||||
|
||||
function copyDeepLink() {
|
||||
if (!invite.value) return
|
||||
copy(deepLink.value)
|
||||
notifications.addNotification({ type: 'info', message: 'Deep link copied to clipboard.' })
|
||||
track('invite_deeplink_copied', { groupId: props.groupId })
|
||||
}
|
||||
|
||||
function shareInvite() {
|
||||
if (!shareSupported || !invite.value) {
|
||||
notifications.addNotification({ type: 'error', message: 'Web Share API not supported in your browser.' })
|
||||
return
|
||||
}
|
||||
share({
|
||||
title: 'Join my Household!',
|
||||
text: `Use this code to join my household: ${invite.value.code}`,
|
||||
url: inviteLink.value
|
||||
})
|
||||
track('invite_shared', { groupId: props.groupId })
|
||||
}
|
||||
|
||||
onMounted(fetchActiveInvite)
|
||||
watch(() => props.groupId, fetchActiveInvite)
|
||||
|
||||
watch(() => props.groupId, fetchActiveInvite, { immediate: true })
|
||||
watch(invite, generateQrCode, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.invite-manager {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
@apply text-sm text-text-secondary mb-4;
|
||||
}
|
||||
|
||||
.active-invite-section {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.invite-display {
|
||||
@apply flex flex-col items-center gap-4 p-4 rounded-lg bg-surface-soft;
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
@apply w-48 h-48 flex items-center justify-center bg-white dark:bg-neutral-900 rounded-lg p-2 shadow-md transition-opacity duration-150 ease-out;
|
||||
}
|
||||
|
||||
.qr-code-placeholder {
|
||||
@apply w-full h-full flex items-center justify-center bg-neutral-100;
|
||||
}
|
||||
|
||||
.invite-code-wrapper {
|
||||
@apply w-full text-center p-3 bg-white dark:bg-neutral-900 rounded-md border border-border-secondary;
|
||||
}
|
||||
|
||||
.invite-code {
|
||||
@apply text-2xl font-bold tracking-widest text-primary-600 dark:text-primary-400 font-mono;
|
||||
}
|
||||
|
||||
.invite-actions {
|
||||
@apply grid grid-cols-2 gap-3;
|
||||
}
|
||||
|
||||
.invite-actions .button {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.invite-footer {
|
||||
@apply flex items-center justify-between text-xs text-text-secondary;
|
||||
}
|
||||
|
||||
.no-invite-section {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.qr-code-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.qr-code-enter-to {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
247
fe/src/components/ListCard.vue
Normal file
247
fe/src/components/ListCard.vue
Normal file
@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<Card :variant="isPinned ? 'elevated' : 'outlined'" :color="list.archived_at ? 'neutral' : 'primary'"
|
||||
:class="['list-card', `view-mode-${viewMode}`, { 'archived': list.archived_at }]" :interactive="true"
|
||||
@click="$emit('open', list)">
|
||||
<div class="card-content">
|
||||
<!-- Card Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-main">
|
||||
<div class="header-icon" :class="`type-${list.type || 'custom'}`">
|
||||
<BaseIcon :name="listIcon" />
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h4 class="list-name">{{ list.name }}</h4>
|
||||
<p class="last-activity">{{ lastActivity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Menu as="div" class="relative">
|
||||
<MenuButton as="template">
|
||||
<Button variant="ghost" size="sm" class="action-button">
|
||||
<BaseIcon name="heroicons:ellipsis-vertical-20-solid" />
|
||||
</Button>
|
||||
</MenuButton>
|
||||
<transition enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0">
|
||||
<MenuItems class="action-menu">
|
||||
<MenuItem v-if="!list.archived_at" v-slot="{ active }">
|
||||
<button :class="['menu-item', { 'active': active }]" @click.stop="$emit('edit', list)">
|
||||
<BaseIcon name="heroicons:pencil-20-solid" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button :class="['menu-item', { 'active': active }]"
|
||||
@click.stop="$emit('archive', list)">
|
||||
<BaseIcon
|
||||
:name="list.archived_at ? 'heroicons:archive-box-arrow-down' : 'heroicons:archive-box-x-mark'" />
|
||||
<span>{{ list.archived_at ? 'Unarchive' : 'Archive' }}</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button :class="['menu-item', 'danger', { 'active': active }]"
|
||||
@click.stop="$emit('delete', list)">
|
||||
<BaseIcon name="heroicons:trash-20-solid" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List Preview -->
|
||||
<div class="list-preview" v-if="list.items && list.items.length > 0">
|
||||
<ul class="preview-items">
|
||||
<li v-for="item in list.items.slice(0, 3)" :key="item.id" class="preview-item">
|
||||
<BaseIcon :name="item.is_complete ? 'heroicons:check-circle-20-solid' : 'heroicons:circle'"
|
||||
:class="['item-icon', { 'completed': item.is_complete }]" />
|
||||
<span :class="{ 'completed-text': item.is_complete }">{{ item.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="list.items.length > 3" class="more-items">
|
||||
+{{ list.items.length - 3 }} more
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-preview">
|
||||
No items in this list yet.
|
||||
</p>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="card-footer">
|
||||
<div class="progress-bar" v-if="completionPercentage > 0">
|
||||
<div class="progress-fill" :style="{ width: `${completionPercentage}%` }"></div>
|
||||
</div>
|
||||
<div class="footer-meta">
|
||||
<span class="item-count">{{ list.items.length }} items</span>
|
||||
<span class="completion-status">{{ completionPercentage }}% complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
import type { List } from '@/types/list'
|
||||
import { Card, Button } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
list: List
|
||||
viewMode: 'grid' | 'list'
|
||||
isPinned?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open', list: List): void
|
||||
(e: 'edit', list: List): void
|
||||
(e: 'archive', list: List): void
|
||||
(e: 'delete', list: List): void
|
||||
}>()
|
||||
|
||||
const listIcon = computed(() => {
|
||||
if (props.list.type === 'shopping') return 'heroicons:shopping-cart-20-solid'
|
||||
if (props.list.type === 'todo') return 'heroicons:check-circle-20-solid'
|
||||
return 'heroicons:list-bullet-20-solid'
|
||||
})
|
||||
|
||||
const lastActivity = computed(() => {
|
||||
const date = props.list.updated_at || props.list.created_at
|
||||
if (!date) return 'No activity yet'
|
||||
return `Updated ${formatDistanceToNow(parseISO(date), { addSuffix: true })}`
|
||||
})
|
||||
|
||||
const completionPercentage = computed(() => {
|
||||
if (!props.list.items || props.list.items.length === 0) return 0
|
||||
const completedCount = props.list.items.filter(item => item.is_complete).length
|
||||
return Math.round((completedCount / props.list.items.length) * 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-card {
|
||||
@apply transition-all duration-micro ease-micro;
|
||||
}
|
||||
|
||||
.list-card.archived {
|
||||
@apply opacity-60 hover:opacity-100;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply flex flex-col h-full;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply flex items-start justify-between;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@apply w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0;
|
||||
}
|
||||
|
||||
.header-icon.type-shopping {
|
||||
@apply bg-success-100 text-success-600 dark:bg-success-900/50 dark:text-success-400;
|
||||
}
|
||||
|
||||
.header-icon.type-todo {
|
||||
@apply bg-primary-100 text-primary-600 dark:bg-primary-900/50 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.header-icon.type-custom {
|
||||
@apply bg-warning-100 text-warning-600 dark:bg-warning-900/50 dark:text-warning-400;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.list-name {
|
||||
@apply font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.last-activity {
|
||||
@apply text-xs text-text-secondary;
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
@apply absolute right-0 mt-2 w-48 origin-top-right;
|
||||
@apply bg-surface-elevated rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none;
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
@apply flex items-center w-full px-3 py-2 text-sm gap-3;
|
||||
}
|
||||
|
||||
.menu-item.active {
|
||||
@apply bg-surface-hover;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
@apply text-error-600 dark:text-error-400;
|
||||
}
|
||||
|
||||
.menu-item.danger.active {
|
||||
@apply bg-error-50 dark:bg-error-900/50;
|
||||
}
|
||||
|
||||
.list-preview {
|
||||
@apply my-4 flex-1;
|
||||
}
|
||||
|
||||
.preview-items {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
@apply flex items-center gap-2 text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
|
||||
.item-icon.completed {
|
||||
@apply text-success-500;
|
||||
}
|
||||
|
||||
.completed-text {
|
||||
@apply line-through text-text-tertiary;
|
||||
}
|
||||
|
||||
.more-items {
|
||||
@apply text-xs text-text-tertiary mt-2;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
@apply text-sm text-text-secondary my-4 flex-1 text-center py-4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply space-y-2 pt-4 border-t border-border-primary;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply h-1.5 bg-surface-soft rounded-full overflow-hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full bg-success-500 rounded-full transition-all duration-medium;
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
@apply flex justify-between text-xs text-text-secondary;
|
||||
}
|
||||
</style>
|
599
fe/src/components/ListDirectory.vue
Normal file
599
fe/src/components/ListDirectory.vue
Normal file
@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="list-directory">
|
||||
<!-- Header -->
|
||||
<div class="directory-header">
|
||||
<div class="header-content">
|
||||
<Heading :level="2" class="page-title">Your Lists</Heading>
|
||||
<div class="header-actions">
|
||||
<Button variant="outline" color="neutral" size="sm" @click="toggleViewMode">
|
||||
<template #icon-left>
|
||||
<BaseIcon :name="viewMode === 'grid' ? 'heroicons:view-list' : 'heroicons:view-grid'" />
|
||||
</template>
|
||||
{{ viewMode === 'grid' ? 'List' : 'Grid' }}
|
||||
</Button>
|
||||
<Button variant="solid" color="primary" @click="showCreateModal = true">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:plus-20-solid" />
|
||||
</template>
|
||||
New List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-section">
|
||||
<TabGroup :selectedIndex="selectedFilter" @change="setSelectedFilter">
|
||||
<TabList class="filter-tabs">
|
||||
<Tab v-for="filter in filters" :key="filter.id" v-slot="{ selected }" as="template">
|
||||
<button :class="['filter-tab', { 'selected': selected }]">
|
||||
<BaseIcon :name="filter.icon" class="filter-icon" />
|
||||
<span class="filter-label">{{ filter.label }}</span>
|
||||
<span v-if="filter.count > 0" class="filter-count">{{ filter.count }}</span>
|
||||
</button>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<Spinner size="lg" />
|
||||
<span class="loading-text">Loading your lists...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<Alert v-else-if="error" type="error" :title="'Failed to load lists'" :message="error" class="mb-6" />
|
||||
|
||||
<!-- Lists Content -->
|
||||
<div v-else class="lists-content">
|
||||
<!-- Pinned Shopping List -->
|
||||
<div v-if="pinnedShoppingList" class="pinned-section">
|
||||
<div class="section-header">
|
||||
<BaseIcon name="heroicons:pin-20-solid"
|
||||
class="section-icon text-primary-600 dark:text-primary-400" />
|
||||
<h3 class="section-title">Pinned List</h3>
|
||||
</div>
|
||||
<ListCard :list="pinnedShoppingList" :view-mode="viewMode" :is-pinned="true" @open="handleOpenList"
|
||||
@edit="handleEditList" @archive="handleArchiveList" @delete="handleDeleteList" />
|
||||
</div>
|
||||
|
||||
<!-- Main Lists Section -->
|
||||
<div v-if="activeLists.length > 0" class="active-section">
|
||||
<div class="section-header">
|
||||
<BaseIcon :name="currentFilter.icon" class="section-icon" />
|
||||
<h3 class="section-title">{{ currentFilter.label }}</h3>
|
||||
<span class="section-count">{{ activeLists.length }}</span>
|
||||
</div>
|
||||
|
||||
<div :class="viewModeClasses">
|
||||
<TransitionGroup name="list-item" tag="div" class="lists-container">
|
||||
<ListCard v-for="list in activeLists" :key="list.id" :list="list" :view-mode="viewMode"
|
||||
@open="handleOpenList" @edit="handleEditList" @archive="handleArchiveList"
|
||||
@delete="handleDeleteList" class="list-card-item" />
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Suggestions -->
|
||||
<div v-if="suggestedLists.length > 0 && selectedFilter !== 3" class="suggestions-section">
|
||||
<div class="section-header">
|
||||
<BaseIcon name="heroicons:light-bulb-20-solid" class="section-icon text-warning-500" />
|
||||
<h3 class="section-title">Start a New List</h3>
|
||||
</div>
|
||||
|
||||
<div class="suggestions-grid">
|
||||
<Card v-for="suggestion in suggestedLists" :key="suggestion.id" variant="elevated" padding="md"
|
||||
:interactive="true" class="suggestion-card" @click="handleCreateFromSuggestion(suggestion)">
|
||||
<div class="suggestion-content">
|
||||
<div class="suggestion-icon-wrapper">
|
||||
<BaseIcon :name="suggestion.icon" class="suggestion-icon" />
|
||||
</div>
|
||||
<div class="suggestion-text">
|
||||
<h4 class="suggestion-title">{{ suggestion.title }}</h4>
|
||||
<p class="suggestion-description">{{ suggestion.description }}</p>
|
||||
</div>
|
||||
<BaseIcon name="heroicons:chevron-right-20-solid" class="suggestion-arrow" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="activeLists.length === 0 && !pinnedShoppingList" class="empty-state">
|
||||
<div class="empty-icon-wrapper">
|
||||
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="empty-icon" />
|
||||
</div>
|
||||
<h3 class="empty-title">{{ currentFilter.emptyTitle }}</h3>
|
||||
<p class="empty-description">{{ currentFilter.emptyDescription }}</p>
|
||||
<Button variant="solid" color="primary" @click="showCreateModal = true">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:plus-20-solid" />
|
||||
</template>
|
||||
Create Your First List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create List Modal -->
|
||||
<CreateListModal v-model="showCreateModal" @created="handleListCreated" :template="creationTemplate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { TabGroup, TabList, Tab } from '@headlessui/vue'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { List } from '@/types/list'
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
|
||||
// UI Components
|
||||
import { Heading, Button, Spinner, Alert, Card } from '@/components/ui'
|
||||
import ListCard from '@/components/ListCard.vue'
|
||||
import CreateListModal from './CreateListModal.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
interface ListFilter {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
count: number
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
filter: (lists: List[]) => List[]
|
||||
}
|
||||
|
||||
interface ListSuggestion {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
template: {
|
||||
name: string
|
||||
description: string
|
||||
type: 'shopping' | 'todo' | 'custom'
|
||||
items?: { name: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Local state for managing lists directly
|
||||
const allLists = ref<List[]>([])
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedFilter = ref(0)
|
||||
const viewMode = useStorage<'grid' | 'list'>('list-directory-view', 'grid')
|
||||
const showCreateModal = ref(false)
|
||||
const creationTemplate = ref<ListSuggestion['template'] | null>(null)
|
||||
|
||||
// --- API Actions ---
|
||||
async function fetchLists() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BASE)
|
||||
allLists.value = response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to load lists.'
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Could not fetch your lists. Please try again.'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveList(list: List) {
|
||||
const isArchiving = !list.archived_at
|
||||
const originalStatus = list.archived_at
|
||||
list.archived_at = isArchiving ? new Date().toISOString() : null
|
||||
|
||||
try {
|
||||
if (isArchiving) {
|
||||
await apiClient.post(API_ENDPOINTS.LISTS.ARCHIVE(String(list.id)))
|
||||
} else {
|
||||
await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(String(list.id)))
|
||||
}
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `"${list.name}" ${isArchiving ? 'archived' : 'unarchived'}.`
|
||||
})
|
||||
} catch (err) {
|
||||
list.archived_at = originalStatus // Rollback on error
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: `Failed to ${isArchiving ? 'archive' : 'unarchive'} list.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteList(list: List) {
|
||||
if (!confirm(`Are you sure you want to permanently delete "${list.name}"? This action cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalLists = [...allLists.value]
|
||||
allLists.value = allLists.value.filter(l => l.id !== list.id)
|
||||
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(String(list.id)))
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `"${list.name}" was permanently deleted.`
|
||||
})
|
||||
} catch (err) {
|
||||
allLists.value = originalLists // Rollback on error
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to delete list.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Computed properties
|
||||
const pinnedShoppingList = computed(() =>
|
||||
allLists.value.find((list: List) =>
|
||||
list.type === 'shopping' &&
|
||||
!list.archived_at
|
||||
)
|
||||
)
|
||||
|
||||
const filters = computed((): ListFilter[] => [
|
||||
{
|
||||
id: 'recent',
|
||||
label: 'Recent',
|
||||
icon: 'heroicons:clock-20-solid',
|
||||
count: recentLists.value.length,
|
||||
emptyTitle: 'No recent activity',
|
||||
emptyDescription: 'Lists you\'ve used recently will appear here.',
|
||||
filter: (lists: List[]) => recentLists.value
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
label: 'Active',
|
||||
icon: 'heroicons:fire-20-solid',
|
||||
count: activeLists.value.length,
|
||||
emptyTitle: 'No active lists',
|
||||
emptyDescription: 'Create a list to start organizing your tasks.',
|
||||
filter: (lists: List[]) => lists.filter((list: List) => !list.archived_at && hasRecentActivity(list))
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All Lists',
|
||||
icon: 'heroicons:list-bullet-20-solid',
|
||||
count: allLists.value.filter((list: List) => !list.archived_at).length,
|
||||
emptyTitle: 'No lists yet',
|
||||
emptyDescription: 'Create your first list to get started.',
|
||||
filter: (lists: List[]) => lists.filter((list: List) => !list.archived_at && list.id !== pinnedShoppingList.value?.id)
|
||||
},
|
||||
{
|
||||
id: 'archived',
|
||||
label: 'Archived',
|
||||
icon: 'heroicons:archive-box-20-solid',
|
||||
count: archivedLists.value.length,
|
||||
emptyTitle: 'No archived lists',
|
||||
emptyDescription: 'Archived lists will appear here.',
|
||||
filter: (lists: List[]) => archivedLists.value
|
||||
}
|
||||
])
|
||||
|
||||
const currentFilter = computed(() => filters.value[selectedFilter.value] || filters.value[0])
|
||||
|
||||
const recentLists = computed(() =>
|
||||
allLists.value
|
||||
.filter((list: List) => !list.archived_at && list.id !== pinnedShoppingList.value?.id)
|
||||
.filter((list: List) => hasRecentActivity(list))
|
||||
.sort((a: List, b: List) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.slice(0, 10)
|
||||
)
|
||||
|
||||
const activeLists = computed(() => {
|
||||
const filtered = currentFilter.value.filter(allLists.value)
|
||||
return filtered.filter((list: List) => list.id !== pinnedShoppingList.value?.id)
|
||||
})
|
||||
|
||||
const archivedLists = computed(() =>
|
||||
allLists.value.filter((list: List) => list.archived_at)
|
||||
)
|
||||
|
||||
const viewModeClasses = computed(() => ({
|
||||
'lists-grid': viewMode.value === 'grid',
|
||||
'lists-list': viewMode.value === 'list'
|
||||
}))
|
||||
|
||||
// List suggestions based on user patterns
|
||||
const suggestedLists = computed((): ListSuggestion[] => {
|
||||
if (allLists.value.length >= 5) return [] // Don't show suggestions if user already has many lists
|
||||
|
||||
const suggestions: ListSuggestion[] = [
|
||||
{
|
||||
id: 'weekly-shopping',
|
||||
title: 'Weekly Groceries',
|
||||
description: 'Organize your grocery shopping with smart categorization.',
|
||||
icon: 'heroicons:shopping-cart-20-solid',
|
||||
template: {
|
||||
name: 'Weekly Groceries',
|
||||
description: 'Smart grocery list with categorization',
|
||||
type: 'shopping',
|
||||
items: [{ name: 'Milk' }, { name: 'Bread' }, { name: 'Eggs' }, { name: 'Fruits & Vegetables' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'weekend-tasks',
|
||||
title: 'Weekend Tasks',
|
||||
description: 'Keep track of things to do on the weekend.',
|
||||
icon: 'heroicons:calendar-days-20-solid',
|
||||
template: {
|
||||
name: 'Weekend Tasks',
|
||||
description: 'Tasks and activities for the weekend',
|
||||
type: 'todo'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'meal-planning',
|
||||
title: 'Meal Planning',
|
||||
description: 'Plan your meals and ingredients for the week.',
|
||||
icon: 'heroicons:cake-20-solid',
|
||||
template: {
|
||||
name: 'Weekly Meal Plan',
|
||||
description: 'Plan meals and required ingredients',
|
||||
type: 'custom',
|
||||
items: [{ name: 'Monday Dinner' }, { name: 'Tuesday Lunch' }, { name: 'Wednesday Dinner' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return suggestions.filter(suggestion => {
|
||||
// Don't suggest if similar list already exists
|
||||
return !allLists.value.some((list: List) =>
|
||||
list.name.toLowerCase().includes(suggestion.template.name.toLowerCase().split(' ')[0])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const hasRecentActivity = (list: List): boolean => {
|
||||
const threeDaysAgo = new Date()
|
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
|
||||
|
||||
const listDate = new Date(list.updated_at)
|
||||
return listDate > threeDaysAgo
|
||||
}
|
||||
|
||||
// Actions
|
||||
const toggleViewMode = () => {
|
||||
viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid'
|
||||
}
|
||||
|
||||
const setSelectedFilter = (index: number) => {
|
||||
selectedFilter.value = index
|
||||
}
|
||||
|
||||
const handleOpenList = (list: List) => {
|
||||
router.push(`/lists/${list.id}`)
|
||||
}
|
||||
|
||||
const handleEditList = (list: List) => {
|
||||
// This could open the CreateListModal in "edit mode"
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: 'Edit functionality coming soon!'
|
||||
})
|
||||
}
|
||||
|
||||
const handleArchiveList = async (list: List) => {
|
||||
await archiveList(list)
|
||||
}
|
||||
|
||||
const handleDeleteList = async (list: List) => {
|
||||
await deleteList(list)
|
||||
}
|
||||
|
||||
const handleCreateFromSuggestion = (suggestion: ListSuggestion) => {
|
||||
creationTemplate.value = suggestion.template
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const handleListCreated = (newList: List) => {
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `"${newList.name}" created successfully!`
|
||||
})
|
||||
|
||||
// Add the new list to our local state and navigate
|
||||
allLists.value.unshift(newList)
|
||||
router.push(`/lists/${newList.id}`)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(fetchLists)
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-directory {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.directory-header {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-neutral-900 dark:text-neutral-100;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
@apply border-b border-neutral-200 dark:border-neutral-700;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
@apply flex items-center gap-2 overflow-x-auto pb-2;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
@apply flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-micro;
|
||||
@apply text-text-secondary hover:text-text-primary;
|
||||
@apply hover:bg-surface-hover;
|
||||
@apply whitespace-nowrap cursor-pointer;
|
||||
}
|
||||
|
||||
.filter-tab.selected {
|
||||
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300;
|
||||
@apply hover:bg-primary-100/80;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
@apply w-5 h-5;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
@apply bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 px-2 py-0.5 rounded-full text-xs font-semibold;
|
||||
}
|
||||
|
||||
.filter-tab.selected .filter-count {
|
||||
@apply bg-primary-200 text-primary-800 dark:bg-primary-500/50 dark:text-primary-200;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
@apply flex flex-col items-center justify-center py-12 space-y-4;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@apply text-neutral-600 dark:text-neutral-400;
|
||||
}
|
||||
|
||||
.lists-content {
|
||||
@apply space-y-8;
|
||||
}
|
||||
|
||||
.pinned-section,
|
||||
.active-section,
|
||||
.suggestions-section {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center gap-2;
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
@apply w-5 h-5 text-text-secondary;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-lg font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
@apply ml-auto bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 px-3 py-1 rounded-full text-sm font-medium;
|
||||
}
|
||||
|
||||
.lists-grid {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4;
|
||||
}
|
||||
|
||||
.lists-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.lists-container {
|
||||
@apply contents;
|
||||
}
|
||||
|
||||
.suggestions-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-4;
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
@apply transition-all duration-micro;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.suggestion-icon-wrapper {
|
||||
@apply w-10 h-10 bg-primary-100 dark:bg-primary-900/50 rounded-lg flex items-center justify-center flex-shrink-0;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
@apply w-5 h-5 text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
@apply font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.suggestion-description {
|
||||
@apply text-sm text-text-secondary mt-0.5;
|
||||
}
|
||||
|
||||
.suggestion-arrow {
|
||||
@apply w-5 h-5 text-text-tertiary transition-transform duration-micro;
|
||||
@apply group-hover:translate-x-1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center py-16 px-6 space-y-4 text-center;
|
||||
@apply bg-surface-soft rounded-lg;
|
||||
}
|
||||
|
||||
.empty-icon-wrapper {
|
||||
@apply w-16 h-16 bg-primary-100 dark:bg-primary-900/50 rounded-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply w-8 h-8 text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@apply text-xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
@apply text-text-secondary max-w-md;
|
||||
}
|
||||
|
||||
/* List item animations */
|
||||
.list-item-enter-active,
|
||||
.list-item-leave-active {
|
||||
@apply transition-all duration-medium ease-medium;
|
||||
}
|
||||
|
||||
.list-item-enter-from {
|
||||
@apply opacity-0 scale-95 translate-y-2;
|
||||
}
|
||||
|
||||
.list-item-leave-to {
|
||||
@apply opacity-0 scale-95 translate-y-2;
|
||||
}
|
||||
|
||||
.list-item-move {
|
||||
@apply transition-transform duration-medium ease-medium;
|
||||
}
|
||||
</style>
|
907
fe/src/components/OnboardingCarousel.vue
Normal file
907
fe/src/components/OnboardingCarousel.vue
Normal file
@ -0,0 +1,907 @@
|
||||
<template>
|
||||
<div class="onboarding-carousel">
|
||||
<!-- Progress Header -->
|
||||
<header class="onboarding-header">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: `${progressPercentage}%` }"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ currentStep + 1 }} of {{ steps.length }}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" @click="$emit('close')" class="skip-button">
|
||||
Skip Setup
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<!-- Step Content -->
|
||||
<main class="onboarding-content">
|
||||
<!-- Step 1: Welcome & Profile -->
|
||||
<div v-if="currentStep === 0" class="onboarding-step welcome-step">
|
||||
<div class="step-hero">
|
||||
<div class="hero-icon">
|
||||
<BaseIcon name="heroicons:home-modern" class="w-16 h-16 text-primary-500" />
|
||||
</div>
|
||||
<Heading :level="2" class="hero-title">Welcome to Your Household Hub!</Heading>
|
||||
<p class="hero-subtitle">Let's get your household organized in under 60 seconds.</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-setup">
|
||||
<Card variant="soft" color="primary" class="profile-card">
|
||||
<div class="profile-header">
|
||||
<BaseIcon name="heroicons:user-circle" />
|
||||
<h3>Set Up Your Profile</h3>
|
||||
</div>
|
||||
|
||||
<div class="profile-form">
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-preview" :class="{ 'has-image': profileImage }">
|
||||
<img v-if="profileImage" :src="profileImage" alt="Profile" class="avatar-image" />
|
||||
<BaseIcon v-else name="heroicons:user" class="avatar-placeholder" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="uploadAvatar">
|
||||
<BaseIcon name="heroicons:camera" />
|
||||
Add Photo
|
||||
</Button>
|
||||
<input type="file" ref="avatarInput" @change="handleAvatarUpload" accept="image/*"
|
||||
hidden />
|
||||
</div>
|
||||
|
||||
<div class="form-fields">
|
||||
<Input v-model="form.name" label="Your Name" placeholder="What should we call you?"
|
||||
class="name-input" />
|
||||
<div class="auto-import" v-if="oauthData">
|
||||
<span class="import-label">Auto-imported from {{ oauthData.provider }}</span>
|
||||
<Button variant="ghost" size="sm" @click="clearOAuthData">
|
||||
<BaseIcon name="heroicons:x-mark" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Household Decision -->
|
||||
<div v-if="currentStep === 1" class="onboarding-step household-step">
|
||||
<div class="step-hero">
|
||||
<Heading :level="2" class="hero-title">Join or Create a Household?</Heading>
|
||||
<p class="hero-subtitle">Choose how you'd like to get started with household management.</p>
|
||||
</div>
|
||||
|
||||
<div class="household-options">
|
||||
<div class="option-cards">
|
||||
<Card class="option-card" :class="{ 'option-selected': householdChoice === 'join' }"
|
||||
@click="householdChoice = 'join'">
|
||||
<div class="option-content">
|
||||
<div class="option-icon">
|
||||
<BaseIcon name="heroicons:user-plus" class="w-12 h-12 text-success-500" />
|
||||
</div>
|
||||
<div class="option-details">
|
||||
<h3 class="option-title">Join Existing Household</h3>
|
||||
<p class="option-description">Someone already set up your household? Join with an
|
||||
invite code.</p>
|
||||
<div class="option-benefits">
|
||||
<div class="benefit-item">
|
||||
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
|
||||
<span>Quick setup</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
|
||||
<span>Existing data preserved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-radio">
|
||||
<div class="radio-dot" :class="{ 'radio-selected': householdChoice === 'join' }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="option-card" :class="{ 'option-selected': householdChoice === 'create' }"
|
||||
@click="householdChoice = 'create'">
|
||||
<div class="option-content">
|
||||
<div class="option-icon">
|
||||
<BaseIcon name="heroicons:plus-circle" class="w-12 h-12 text-primary-500" />
|
||||
</div>
|
||||
<div class="option-details">
|
||||
<h3 class="option-title">Create New Household</h3>
|
||||
<p class="option-description">Start fresh with a new household that you can invite
|
||||
others to join.</p>
|
||||
<div class="option-benefits">
|
||||
<div class="benefit-item">
|
||||
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
|
||||
<span>Full control</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<BaseIcon name="heroicons:check-circle" class="benefit-icon" />
|
||||
<span>Customize everything</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option-radio">
|
||||
<div class="radio-dot" :class="{ 'radio-selected': householdChoice === 'create' }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Invite Code Input (if joining) -->
|
||||
<div v-if="householdChoice === 'join'" class="invite-section">
|
||||
<Card variant="soft" color="success" class="invite-card">
|
||||
<div class="invite-header">
|
||||
<BaseIcon name="heroicons:key" />
|
||||
<h4>Enter Invite Code</h4>
|
||||
</div>
|
||||
<div class="invite-input-section">
|
||||
<Input v-model="inviteCode" label="Invite Code" placeholder="XXXX-XXXX-XXXX"
|
||||
class="invite-input" @input="formatInviteCode" />
|
||||
<Button variant="outline" @click="scanQRCode" class="qr-scan-button">
|
||||
<BaseIcon name="heroicons:qr-code" />
|
||||
Scan QR
|
||||
</Button>
|
||||
</div>
|
||||
<p class="invite-help">Ask your household member for the invite code or QR code.</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Household Name Input (if creating) -->
|
||||
<div v-if="householdChoice === 'create'" class="household-name-section">
|
||||
<Card variant="soft" color="primary" class="household-card">
|
||||
<div class="household-header">
|
||||
<BaseIcon name="heroicons:home" />
|
||||
<h4>Name Your Household</h4>
|
||||
</div>
|
||||
<Input v-model="householdName" label="Household Name"
|
||||
placeholder="e.g., The Smith Family, Apartment 4B..." class="household-input" />
|
||||
<p class="household-help">Choose a name that everyone in your household will recognize.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Feature Showcase -->
|
||||
<div v-if="currentStep === 2" class="onboarding-step features-step">
|
||||
<div class="step-hero">
|
||||
<Heading :level="2" class="hero-title">Here's What You Can Do</Heading>
|
||||
<p class="hero-subtitle">A quick tour of features that will transform your household management.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<Card v-for="feature in features" :key="feature.id" class="feature-card" variant="soft"
|
||||
:color="feature.color">
|
||||
<div class="feature-content">
|
||||
<div class="feature-icon">
|
||||
<BaseIcon :name="feature.icon" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="feature-details">
|
||||
<h3 class="feature-title">{{ feature.title }}</h3>
|
||||
<p class="feature-description">{{ feature.description }}</p>
|
||||
<div class="feature-example">
|
||||
<span class="example-label">Example:</span>
|
||||
<span class="example-text">{{ feature.example }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="features-footer">
|
||||
<Card variant="soft" color="warning" class="tip-card">
|
||||
<div class="tip-content">
|
||||
<BaseIcon name="heroicons:light-bulb" class="tip-icon" />
|
||||
<div>
|
||||
<h4 class="tip-title">Pro Tip</h4>
|
||||
<p class="tip-text">Start with just one feature - maybe shared shopping lists - and
|
||||
gradually explore others as your household gets comfortable.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Setup Complete -->
|
||||
<div v-if="currentStep === 3" class="onboarding-step complete-step">
|
||||
<div class="completion-hero">
|
||||
<div class="success-animation">
|
||||
<BaseIcon name="heroicons:check-circle" class="success-icon" />
|
||||
</div>
|
||||
<Heading :level="2" class="completion-title">You're All Set!</Heading>
|
||||
<p class="completion-subtitle">Your household management hub is ready to use.</p>
|
||||
</div>
|
||||
|
||||
<div class="next-steps">
|
||||
<Card variant="soft" color="success" class="next-steps-card">
|
||||
<div class="next-steps-header">
|
||||
<BaseIcon name="heroicons:rocket-launch" />
|
||||
<h3>Recommended First Steps</h3>
|
||||
</div>
|
||||
|
||||
<div class="next-steps-list">
|
||||
<div v-for="step in nextSteps" :key="step.id" class="next-step-item">
|
||||
<div class="step-number">{{ step.id }}</div>
|
||||
<div class="step-content">
|
||||
<span class="step-title">{{ step.title }}</span>
|
||||
<span class="step-time">{{ step.time }}</span>
|
||||
</div>
|
||||
<BaseIcon name="heroicons:arrow-right" class="step-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="completion-actions">
|
||||
<Button @click="goToDashboard" size="lg" class="dashboard-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:home" />
|
||||
</template>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" @click="inviteMembers" size="lg" class="invite-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:user-plus" />
|
||||
</template>
|
||||
Invite Household Members
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<footer class="onboarding-footer">
|
||||
<div class="footer-actions">
|
||||
<Button v-if="currentStep > 0" variant="outline" @click="prevStep" :disabled="processing"
|
||||
class="back-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:arrow-left" />
|
||||
</template>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<Button v-if="currentStep < steps.length - 1" @click="nextStep" :disabled="!canProceed"
|
||||
:loading="processing" size="lg" class="next-button">
|
||||
{{ getNextButtonText() }}
|
||||
<template #icon-right>
|
||||
<BaseIcon name="heroicons:arrow-right" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Heading, Button, Card, Input } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useGroupStore } from '@/stores/groupStore'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
|
||||
interface Step {
|
||||
id: string
|
||||
title: string
|
||||
canSkip: boolean
|
||||
}
|
||||
|
||||
interface Feature {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
example: string
|
||||
icon: string
|
||||
color: 'primary' | 'success' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
interface NextStep {
|
||||
id: number
|
||||
title: string
|
||||
time: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
complete: [householdId?: number]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const groupStore = useGroupStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
// Steps configuration
|
||||
const steps: Step[] = [
|
||||
{ id: 'profile', title: 'Profile Setup', canSkip: false },
|
||||
{ id: 'household', title: 'Household Choice', canSkip: false },
|
||||
{ id: 'features', title: 'Feature Tour', canSkip: true },
|
||||
{ id: 'complete', title: 'Setup Complete', canSkip: false }
|
||||
]
|
||||
|
||||
// Current state
|
||||
const currentStep = ref(0)
|
||||
const processing = ref(false)
|
||||
|
||||
// Form data
|
||||
const form = ref({
|
||||
name: auth.user?.name || ''
|
||||
})
|
||||
|
||||
const profileImage = ref<string | null>(auth.user?.avatarUrl || null)
|
||||
const avatarInput = ref<HTMLInputElement>()
|
||||
|
||||
// OAuth data (simulated - would come from actual OAuth flow)
|
||||
const oauthData = ref<{ provider: string; name: string; image?: string } | null>(
|
||||
auth.user ? { provider: 'Google', name: auth.user.name } : null
|
||||
)
|
||||
|
||||
// Household setup
|
||||
const householdChoice = ref<'join' | 'create' | ''>('')
|
||||
const inviteCode = ref('')
|
||||
const householdName = ref('')
|
||||
|
||||
// Features showcase
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'chores',
|
||||
title: 'Smart Chore Management',
|
||||
description: 'Fair assignment, automatic rotation, and point-based rewards keep everyone motivated.',
|
||||
example: '"Take out trash" auto-assigned to Jake, due tomorrow',
|
||||
icon: 'heroicons:clipboard-document-check',
|
||||
color: 'primary'
|
||||
},
|
||||
{
|
||||
id: 'shopping',
|
||||
title: 'Collaborative Shopping',
|
||||
description: 'Real-time shared lists prevent duplicate purchases and ensure nothing is forgotten.',
|
||||
example: 'Sarah adds "Milk" → Jake sees it instantly at the store',
|
||||
icon: 'heroicons:shopping-cart',
|
||||
color: 'success'
|
||||
},
|
||||
{
|
||||
id: 'expenses',
|
||||
title: 'Automatic Bill Splitting',
|
||||
description: 'Snap receipts, split fairly, and settle debts with built-in payment tracking.',
|
||||
example: 'Dinner receipt: $60 → Auto-split 3 ways = $20 each',
|
||||
icon: 'heroicons:receipt-percent',
|
||||
color: 'warning'
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
title: 'Household Activity Feed',
|
||||
description: 'Stay connected with real-time updates on completed tasks and shared expenses.',
|
||||
example: 'Alex completed "Clean kitchen" +15 points!',
|
||||
icon: 'heroicons:bell',
|
||||
color: 'error'
|
||||
}
|
||||
]
|
||||
|
||||
// Next steps recommendations
|
||||
const nextSteps: NextStep[] = [
|
||||
{ id: 1, title: 'Add your first shared shopping list', time: '2 min' },
|
||||
{ id: 2, title: 'Set up recurring household chores', time: '5 min' },
|
||||
{ id: 3, title: 'Invite other household members', time: '1 min' }
|
||||
]
|
||||
|
||||
// Computed properties
|
||||
const progressPercentage = computed(() => {
|
||||
return ((currentStep.value + 1) / steps.length) * 100
|
||||
})
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 0: // Profile setup
|
||||
return form.value.name.trim().length > 0
|
||||
case 1: // Household choice
|
||||
if (householdChoice.value === 'join') {
|
||||
return inviteCode.value.trim().length >= 12 // Basic validation
|
||||
}
|
||||
if (householdChoice.value === 'create') {
|
||||
return householdName.value.trim().length > 0
|
||||
}
|
||||
return householdChoice.value !== ''
|
||||
case 2: // Features (can always proceed)
|
||||
return true
|
||||
case 3: // Complete (no next step)
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
function nextStep() {
|
||||
if (currentStep.value < steps.length - 1) {
|
||||
if (currentStep.value === 1) {
|
||||
// Process household setup before moving to features
|
||||
processHouseholdSetup()
|
||||
} else {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
function getNextButtonText() {
|
||||
switch (currentStep.value) {
|
||||
case 0:
|
||||
return 'Continue'
|
||||
case 1:
|
||||
return householdChoice.value === 'join' ? 'Join Household' : 'Create Household'
|
||||
case 2:
|
||||
return 'Finish Setup'
|
||||
default:
|
||||
return 'Next'
|
||||
}
|
||||
}
|
||||
|
||||
async function processHouseholdSetup() {
|
||||
processing.value = true
|
||||
try {
|
||||
if (householdChoice.value === 'join') {
|
||||
await joinHousehold()
|
||||
} else if (householdChoice.value === 'create') {
|
||||
await createHousehold()
|
||||
}
|
||||
currentStep.value++
|
||||
} catch (error) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to set up household. Please try again.'
|
||||
})
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function joinHousehold() {
|
||||
// API call to join household with invite code
|
||||
const cleanCode = inviteCode.value.replace(/[-\s]/g, '')
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
notifications.addNotification({
|
||||
type: 'success',
|
||||
message: 'Successfully joined household!'
|
||||
})
|
||||
}
|
||||
|
||||
async function createHousehold() {
|
||||
// API call to create new household
|
||||
const householdData = {
|
||||
name: householdName.value.trim(),
|
||||
created_by: auth.user?.id
|
||||
}
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
notifications.addNotification({
|
||||
type: 'success',
|
||||
message: `Created "${householdName.value}" household!`
|
||||
})
|
||||
}
|
||||
|
||||
function uploadAvatar() {
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
|
||||
function handleAvatarUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
profileImage.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function clearOAuthData() {
|
||||
oauthData.value = null
|
||||
form.value.name = ''
|
||||
}
|
||||
|
||||
function formatInviteCode(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
let value = input.value.replace(/[^A-Z0-9]/gi, '').toUpperCase()
|
||||
|
||||
// Format as XXXX-XXXX-XXXX
|
||||
if (value.length > 0) {
|
||||
value = value.match(/.{1,4}/g)?.join('-') || value
|
||||
if (value.length > 14) {
|
||||
value = value.substring(0, 14)
|
||||
}
|
||||
}
|
||||
|
||||
inviteCode.value = value
|
||||
nextTick(() => {
|
||||
input.value = value
|
||||
})
|
||||
}
|
||||
|
||||
function scanQRCode() {
|
||||
// Would implement QR code scanning here
|
||||
notifications.addNotification({
|
||||
type: 'info',
|
||||
message: 'QR code scanning would open camera interface'
|
||||
})
|
||||
}
|
||||
|
||||
function goToDashboard() {
|
||||
router.push('/')
|
||||
emit('complete')
|
||||
}
|
||||
|
||||
function inviteMembers() {
|
||||
router.push('/household/settings')
|
||||
emit('complete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onboarding-carousel {
|
||||
@apply bg-surface-primary rounded-lg w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.onboarding-header {
|
||||
@apply flex items-center justify-between p-6 border-b border-border-primary bg-surface-soft;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
@apply flex items-center gap-4 flex-1;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply w-64 h-2 bg-border-secondary rounded-full overflow-hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full bg-primary-500 transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
@apply text-sm font-medium text-text-secondary;
|
||||
}
|
||||
|
||||
.skip-button {
|
||||
@apply text-text-tertiary hover:text-text-secondary;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.onboarding-content {
|
||||
@apply flex-1 overflow-y-auto p-8;
|
||||
}
|
||||
|
||||
.onboarding-step {
|
||||
@apply max-w-3xl mx-auto space-y-8;
|
||||
}
|
||||
|
||||
/* Step Hero */
|
||||
.step-hero {
|
||||
@apply text-center space-y-4;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
@apply flex justify-center mb-6;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
@apply text-3xl font-bold text-text-primary;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
@apply text-lg text-text-secondary max-w-2xl mx-auto;
|
||||
}
|
||||
|
||||
/* Welcome Step */
|
||||
.profile-card {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
@apply flex items-center gap-3 mb-6;
|
||||
}
|
||||
|
||||
.profile-header h3 {
|
||||
@apply text-xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
@apply flex flex-col md:flex-row gap-6 items-start;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
@apply flex flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
@apply w-24 h-24 rounded-full border-2 border-border-secondary overflow-hidden flex items-center justify-center;
|
||||
@apply bg-surface-secondary;
|
||||
}
|
||||
|
||||
.has-image {
|
||||
@apply border-primary-300;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
@apply w-12 h-12 text-text-tertiary;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
@apply flex-1 space-y-4;
|
||||
}
|
||||
|
||||
.name-input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.auto-import {
|
||||
@apply flex items-center gap-2 text-sm text-success-600 bg-success-50 dark:bg-success-950/20 px-3 py-2 rounded-md;
|
||||
}
|
||||
|
||||
/* Household Step */
|
||||
.household-options {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.option-cards {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
|
||||
}
|
||||
|
||||
.option-card {
|
||||
@apply cursor-pointer transition-all duration-200 hover:border-primary-300;
|
||||
}
|
||||
|
||||
.option-selected {
|
||||
@apply border-primary-500 bg-primary-50 dark:bg-primary-950/20;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
@apply p-6 flex flex-col items-center text-center space-y-4 relative;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.option-details {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
@apply text-xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
.option-benefits {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
@apply flex items-center gap-2 text-sm text-success-600;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
|
||||
.option-radio {
|
||||
@apply absolute top-4 right-4 w-6 h-6 rounded-full border-2 border-border-secondary flex items-center justify-center;
|
||||
}
|
||||
|
||||
.radio-dot {
|
||||
@apply w-3 h-3 rounded-full bg-transparent transition-colors duration-200;
|
||||
}
|
||||
|
||||
.option-selected .option-radio {
|
||||
@apply border-primary-500;
|
||||
}
|
||||
|
||||
.option-selected .radio-dot {
|
||||
@apply bg-primary-500;
|
||||
}
|
||||
|
||||
/* Invite Section */
|
||||
.invite-section,
|
||||
.household-name-section {
|
||||
@apply max-w-md mx-auto;
|
||||
}
|
||||
|
||||
.invite-card,
|
||||
.household-card {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.invite-header,
|
||||
.household-header {
|
||||
@apply flex items-center gap-3 mb-4;
|
||||
}
|
||||
|
||||
.invite-header h4,
|
||||
.household-header h4 {
|
||||
@apply text-lg font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.invite-input-section {
|
||||
@apply flex gap-3 mb-3;
|
||||
}
|
||||
|
||||
.invite-input {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.qr-scan-button {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.invite-help,
|
||||
.household-help {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
/* Features Step */
|
||||
.features-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@apply flex gap-4;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.feature-details {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@apply text-lg font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
.feature-example {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.example-label {
|
||||
@apply text-xs font-medium text-text-tertiary uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.example-text {
|
||||
@apply text-sm font-mono text-text-secondary bg-surface-secondary px-2 py-1 rounded;
|
||||
}
|
||||
|
||||
.features-footer {
|
||||
@apply mt-8;
|
||||
}
|
||||
|
||||
.tip-card {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
@apply flex gap-3;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
@apply w-6 h-6 text-warning-500 shrink-0;
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
@apply font-semibold text-text-primary mb-1;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
/* Complete Step */
|
||||
.completion-hero {
|
||||
@apply text-center space-y-6;
|
||||
}
|
||||
|
||||
.success-animation {
|
||||
@apply flex justify-center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
@apply w-20 h-20 text-success-500 animate-pulse;
|
||||
}
|
||||
|
||||
.completion-title {
|
||||
@apply text-3xl font-bold text-text-primary;
|
||||
}
|
||||
|
||||
.completion-subtitle {
|
||||
@apply text-lg text-text-secondary;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.next-steps-card {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.next-steps-header {
|
||||
@apply flex items-center gap-3 mb-6;
|
||||
}
|
||||
|
||||
.next-steps-header h3 {
|
||||
@apply text-xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.next-steps-list {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.next-step-item {
|
||||
@apply flex items-center gap-4 p-4 bg-surface-primary rounded-lg;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
@apply w-8 h-8 bg-success-500 text-white rounded-full flex items-center justify-center font-bold text-sm;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
@apply flex-1 flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
@apply font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
@apply w-5 h-5 text-text-tertiary;
|
||||
}
|
||||
|
||||
.completion-actions {
|
||||
@apply flex flex-col sm:flex-row gap-4 justify-center;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.onboarding-footer {
|
||||
@apply p-6 border-t border-border-primary bg-surface-soft;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.next-button {
|
||||
@apply min-w-[140px];
|
||||
}
|
||||
</style>
|
@ -1,41 +1,647 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input v-model="choreName" @keyup.enter="handleAdd" type="text" placeholder="Add a quick chore..."
|
||||
class="flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-neutral-700 focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm bg-white dark:bg-neutral-800 text-gray-900 dark:text-gray-100" />
|
||||
<Button variant="solid" size="sm" :disabled="!choreName || isLoading" @click="handleAdd">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<Card variant="outlined" color="neutral" padding="md" class="quick-chore-add">
|
||||
<div class="add-container">
|
||||
<!-- Quick Add Input -->
|
||||
<div class="input-group">
|
||||
<div class="input-wrapper">
|
||||
<Input ref="inputRef" v-model="choreName" :placeholder="placeholderText" :loading="isLoading"
|
||||
size="md" class="quick-input" @keyup.enter="handleAdd" @focus="handleFocus" @blur="handleBlur"
|
||||
@input="handleInput">
|
||||
<template #prefix>
|
||||
<BaseIcon name="heroicons:plus-20-solid" class="input-icon" />
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- Suggestions Dropdown -->
|
||||
<Transition name="dropdown">
|
||||
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="suggestions-dropdown">
|
||||
<div v-for="(suggestion, index) in filteredSuggestions" :key="suggestion.id" :class="[
|
||||
'suggestion-item',
|
||||
{ 'selected': selectedSuggestionIndex === index }
|
||||
]" @click="selectSuggestion(suggestion)" @mouseenter="selectedSuggestionIndex = index">
|
||||
<div class="suggestion-content">
|
||||
<div class="suggestion-icon">
|
||||
<BaseIcon :name="suggestion.icon" />
|
||||
</div>
|
||||
<div class="suggestion-text">
|
||||
<span class="suggestion-title">{{ suggestion.name }}</span>
|
||||
<span v-if="suggestion.description" class="suggestion-description">
|
||||
{{ suggestion.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="suggestion.frequency" class="suggestion-meta">
|
||||
<span class="frequency-badge">{{ suggestion.frequency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<Button variant="solid" color="primary" size="md" :disabled="!canAdd" :loading="isLoading"
|
||||
@click="() => handleAdd()" class="add-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:plus-20-solid" />
|
||||
</template>
|
||||
<span class="sr-only md:not-sr-only">Add</span>
|
||||
</Button>
|
||||
|
||||
<!-- Smart Actions -->
|
||||
<Button v-if="hasSmartSuggestions" variant="soft" color="success" size="md"
|
||||
@click="showSmartSuggestions = !showSmartSuggestions" class="smart-button"
|
||||
:class="{ 'active': showSmartSuggestions }">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:sparkles-20-solid" />
|
||||
</template>
|
||||
<span class="sr-only md:not-sr-only">Smart</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Suggestions Panel -->
|
||||
<Transition name="expand">
|
||||
<div v-if="showSmartSuggestions && smartSuggestions.length > 0" class="smart-panel">
|
||||
<div class="smart-header">
|
||||
<div class="smart-title">
|
||||
<BaseIcon name="heroicons:lightbulb-20-solid" class="title-icon" />
|
||||
<span>Quick suggestions</span>
|
||||
</div>
|
||||
<span class="smart-count">{{ smartSuggestions.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="smart-suggestions">
|
||||
<button v-for="suggestion in smartSuggestions" :key="suggestion.id"
|
||||
@click="selectSuggestion(suggestion)" class="smart-suggestion">
|
||||
<div class="suggestion-icon">
|
||||
<BaseIcon :name="suggestion.icon" />
|
||||
</div>
|
||||
<div class="suggestion-content">
|
||||
<span class="suggestion-name">{{ suggestion.name }}</span>
|
||||
<span v-if="suggestion.description" class="suggestion-desc">
|
||||
{{ suggestion.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="suggestion-action">
|
||||
<BaseIcon name="heroicons:plus-circle-20-solid" class="plus-icon" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Quick Tips -->
|
||||
<div v-if="showTips" class="quick-tips">
|
||||
<div class="tip-content">
|
||||
<BaseIcon name="heroicons:information-circle-20-solid" class="tip-icon" />
|
||||
<span class="tip-text">{{ currentTip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useChoreStore } from '@/stores/choreStore'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { Card, Input, Button } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
interface ChoreSuggestion {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
icon: string
|
||||
frequency?: string
|
||||
template?: {
|
||||
description: string
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'one_time'
|
||||
type: 'personal' | 'group'
|
||||
}
|
||||
}
|
||||
|
||||
const choreStore = useChoreStore()
|
||||
const authStore = useAuthStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// State
|
||||
const inputRef = ref<InstanceType<typeof Input>>()
|
||||
const choreName = ref('')
|
||||
const isLoading = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const showSuggestions = ref(false)
|
||||
const showSmartSuggestions = ref(false)
|
||||
const selectedSuggestionIndex = ref(-1)
|
||||
const suggestions = ref<ChoreSuggestion[]>([])
|
||||
|
||||
// Smart contextual tips
|
||||
const tips = [
|
||||
"Try 'Water plants' or 'Take out trash'",
|
||||
"Use frequency hints like 'Weekly laundry'",
|
||||
"Quick chores get done faster!",
|
||||
"Daily chores build great habits"
|
||||
]
|
||||
const currentTipIndex = ref(0)
|
||||
|
||||
// Computed
|
||||
const canAdd = computed(() => choreName.value.trim().length > 0)
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
if (isLoading.value) return 'Adding chore...'
|
||||
if (isFocused.value) return 'What needs to be done?'
|
||||
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Add a morning task...'
|
||||
if (hour < 17) return 'Add an afternoon task...'
|
||||
return 'Add an evening task...'
|
||||
})
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
if (!choreName.value.trim()) return []
|
||||
|
||||
const query = choreName.value.toLowerCase()
|
||||
return suggestions.value
|
||||
.filter(suggestion =>
|
||||
suggestion.name.toLowerCase().includes(query) ||
|
||||
suggestion.description?.toLowerCase().includes(query)
|
||||
)
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const smartSuggestions = computed(() => {
|
||||
return suggestions.value
|
||||
.filter(suggestion => suggestion.template)
|
||||
.slice(0, 6)
|
||||
})
|
||||
|
||||
const hasSmartSuggestions = computed(() => smartSuggestions.value.length > 0)
|
||||
|
||||
const showTips = computed(() =>
|
||||
!isFocused.value && !choreName.value && !showSmartSuggestions.value
|
||||
)
|
||||
|
||||
const currentTip = computed(() => tips[currentTipIndex.value])
|
||||
|
||||
// Generate chore suggestions based on common patterns
|
||||
const generateSuggestions = (): ChoreSuggestion[] => {
|
||||
const commonChores: ChoreSuggestion[] = [
|
||||
// Daily tasks
|
||||
{
|
||||
id: 'make-bed',
|
||||
name: 'Make bed',
|
||||
description: 'Start the day organized',
|
||||
icon: 'heroicons:home-20-solid',
|
||||
frequency: 'Daily',
|
||||
template: {
|
||||
description: 'Make bed and tidy bedroom',
|
||||
frequency: 'daily',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dishes',
|
||||
name: 'Do dishes',
|
||||
description: 'Keep kitchen clean',
|
||||
icon: 'heroicons:beaker-20-solid',
|
||||
frequency: 'Daily',
|
||||
template: {
|
||||
description: 'Wash dishes and clean kitchen',
|
||||
frequency: 'daily',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'take-out-trash',
|
||||
name: 'Take out trash',
|
||||
description: 'Empty all waste bins',
|
||||
icon: 'heroicons:trash-20-solid',
|
||||
frequency: 'Weekly',
|
||||
template: {
|
||||
description: 'Empty trash bins and take to curb',
|
||||
frequency: 'weekly',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
|
||||
// Weekly tasks
|
||||
{
|
||||
id: 'laundry',
|
||||
name: 'Do laundry',
|
||||
description: 'Wash, dry, and fold clothes',
|
||||
icon: 'heroicons:cog-6-tooth-20-solid',
|
||||
frequency: 'Weekly',
|
||||
template: {
|
||||
description: 'Complete laundry cycle and put away clothes',
|
||||
frequency: 'weekly',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vacuum',
|
||||
name: 'Vacuum floors',
|
||||
description: 'Clean all carpeted areas',
|
||||
icon: 'heroicons:home-modern-20-solid',
|
||||
frequency: 'Weekly',
|
||||
template: {
|
||||
description: 'Vacuum all rooms and common areas',
|
||||
frequency: 'weekly',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'grocery-shopping',
|
||||
name: 'Grocery shopping',
|
||||
description: 'Buy weekly groceries',
|
||||
icon: 'heroicons:shopping-cart-20-solid',
|
||||
frequency: 'Weekly',
|
||||
template: {
|
||||
description: 'Plan meals and buy groceries for the week',
|
||||
frequency: 'weekly',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
|
||||
// Monthly tasks
|
||||
{
|
||||
id: 'clean-bathroom',
|
||||
name: 'Deep clean bathroom',
|
||||
description: 'Thorough bathroom cleaning',
|
||||
icon: 'heroicons:sparkles-20-solid',
|
||||
frequency: 'Monthly',
|
||||
template: {
|
||||
description: 'Deep clean bathroom including scrubbing and disinfecting',
|
||||
frequency: 'monthly',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'organize-closet',
|
||||
name: 'Organize closet',
|
||||
description: 'Sort and arrange clothing',
|
||||
icon: 'heroicons:squares-2x2-20-solid',
|
||||
frequency: 'Monthly',
|
||||
template: {
|
||||
description: 'Sort clothes, donate unused items, organize by season',
|
||||
frequency: 'monthly',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
|
||||
// One-time tasks
|
||||
{
|
||||
id: 'water-plants',
|
||||
name: 'Water plants',
|
||||
description: 'Care for houseplants',
|
||||
icon: 'heroicons:beaker-20-solid',
|
||||
frequency: 'As needed',
|
||||
template: {
|
||||
description: 'Water all houseplants and check for care needs',
|
||||
frequency: 'one_time',
|
||||
type: 'personal'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pay-bills',
|
||||
name: 'Pay bills',
|
||||
description: 'Handle monthly payments',
|
||||
icon: 'heroicons:banknotes-20-solid',
|
||||
frequency: 'Monthly',
|
||||
template: {
|
||||
description: 'Review and pay monthly bills and subscriptions',
|
||||
frequency: 'monthly',
|
||||
type: 'personal'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return commonChores
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true
|
||||
showSuggestions.value = true
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay to allow suggestion clicks
|
||||
setTimeout(() => {
|
||||
isFocused.value = false
|
||||
showSuggestions.value = false
|
||||
selectedSuggestionIndex.value = -1
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
if (choreName.value.trim()) {
|
||||
showSuggestions.value = true
|
||||
selectedSuggestionIndex.value = -1
|
||||
} else {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
selectedSuggestionIndex.value = Math.min(
|
||||
selectedSuggestionIndex.value + 1,
|
||||
filteredSuggestions.value.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, -1)
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (selectedSuggestionIndex.value >= 0) {
|
||||
selectSuggestion(filteredSuggestions.value[selectedSuggestionIndex.value])
|
||||
} else {
|
||||
handleAdd()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
showSuggestions.value = false
|
||||
selectedSuggestionIndex.value = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const selectSuggestion = (suggestion: ChoreSuggestion) => {
|
||||
choreName.value = suggestion.name
|
||||
showSuggestions.value = false
|
||||
showSmartSuggestions.value = false
|
||||
selectedSuggestionIndex.value = -1
|
||||
|
||||
// Auto-add if it's a template suggestion
|
||||
if (suggestion.template) {
|
||||
nextTick(() => {
|
||||
handleAdd(suggestion)
|
||||
})
|
||||
} else {
|
||||
// Focus input for potential editing
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async (suggestion?: ChoreSuggestion) => {
|
||||
if (!canAdd.value || isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!choreName.value.trim()) return
|
||||
try {
|
||||
isLoading.value = true
|
||||
await choreStore.create({
|
||||
const choreData = {
|
||||
name: choreName.value.trim(),
|
||||
description: '',
|
||||
frequency: 'one_time',
|
||||
type: 'personal',
|
||||
description: suggestion?.template?.description || '',
|
||||
frequency: suggestion?.template?.frequency || 'one_time',
|
||||
type: suggestion?.template?.type || 'personal',
|
||||
custom_interval_days: undefined,
|
||||
next_due_date: new Date().toISOString().split('T')[0],
|
||||
created_by_id: 0, // backend will override
|
||||
} as any)
|
||||
}
|
||||
|
||||
await choreStore.create(choreData as any)
|
||||
|
||||
// Success feedback
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `✨ "${choreName.value.trim()}" added successfully!`,
|
||||
})
|
||||
|
||||
// Haptic feedback
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate([50, 50, 100])
|
||||
}
|
||||
|
||||
// Reset form
|
||||
choreName.value = ''
|
||||
} catch (e) {
|
||||
// Optionally handle error
|
||||
console.error('Failed to create quick chore', e)
|
||||
showSmartSuggestions.value = false
|
||||
selectedSuggestionIndex.value = -1
|
||||
|
||||
// Focus input for continued adding
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus?.()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create quick chore', error)
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to add chore. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
suggestions.value = generateSuggestions()
|
||||
|
||||
// Cycle tips every 5 seconds
|
||||
setInterval(() => {
|
||||
currentTipIndex.value = (currentTipIndex.value + 1) % tips.length
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
// Add keyboard event listener when focused
|
||||
watch(isFocused, (focused) => {
|
||||
if (focused) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quick-chore-add {
|
||||
@apply bg-gradient-to-r from-primary-50 to-primary-50/30 dark:from-primary-900 dark:to-primary-900/30;
|
||||
@apply border-primary-200 dark:border-primary-800;
|
||||
@apply transition-all duration-micro hover:shadow-medium;
|
||||
}
|
||||
|
||||
.add-container {
|
||||
@apply space-y-spacing-sm;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
@apply flex items-center gap-spacing-sm;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@apply flex-1 relative;
|
||||
}
|
||||
|
||||
.quick-input {
|
||||
@apply transition-all duration-micro;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
@apply w-4 h-4 text-text-secondary;
|
||||
}
|
||||
|
||||
.suggestions-dropdown {
|
||||
@apply absolute top-full left-0 right-0 z-50 mt-1;
|
||||
@apply bg-surface-primary border border-border-secondary rounded-radius-lg shadow-elevation-medium;
|
||||
@apply max-h-64 overflow-y-auto;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
@apply px-spacing-sm py-spacing-xs cursor-pointer transition-colors duration-micro;
|
||||
@apply border-b border-border-subtle last:border-b-0;
|
||||
@apply hover:bg-primary-50 dark:hover:bg-primary-950/30;
|
||||
}
|
||||
|
||||
.suggestion-item.selected {
|
||||
@apply bg-primary-100 dark:bg-primary-900/50 text-primary-900 dark:text-primary-100;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
@apply flex items-center gap-spacing-sm;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
@apply w-8 h-8 bg-surface-secondary rounded-radius-lg flex items-center justify-center flex-shrink-0;
|
||||
}
|
||||
|
||||
.suggestion-text {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
@apply block font-medium text-text-primary truncate;
|
||||
}
|
||||
|
||||
.suggestion-description {
|
||||
@apply block text-label-sm text-text-secondary truncate;
|
||||
}
|
||||
|
||||
.suggestion-meta {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.frequency-badge {
|
||||
@apply px-spacing-xs py-1 bg-success-100 dark:bg-success-900/50 text-success-700 dark:text-success-300;
|
||||
@apply text-label-xs font-medium rounded-radius-sm;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@apply flex items-center gap-spacing-xs;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
@apply transition-transform duration-micro hover:scale-105 active:scale-95;
|
||||
}
|
||||
|
||||
.smart-button {
|
||||
@apply transition-all duration-micro;
|
||||
}
|
||||
|
||||
.smart-button.active {
|
||||
@apply bg-success-100 dark:bg-success-900/50 text-success-700 dark:text-success-300;
|
||||
@apply ring-2 ring-success-200 dark:ring-success-800;
|
||||
}
|
||||
|
||||
.smart-panel {
|
||||
@apply bg-surface-secondary border border-border-secondary rounded-radius-lg p-spacing-sm;
|
||||
}
|
||||
|
||||
.smart-header {
|
||||
@apply flex items-center justify-between mb-spacing-sm;
|
||||
}
|
||||
|
||||
.smart-title {
|
||||
@apply flex items-center gap-spacing-xs text-body-sm font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
@apply w-4 h-4 text-warning-500;
|
||||
}
|
||||
|
||||
.smart-count {
|
||||
@apply px-spacing-xs py-1 bg-warning-100 dark:bg-warning-900/50 text-warning-700 dark:text-warning-300;
|
||||
@apply text-label-xs font-medium rounded-radius-full;
|
||||
}
|
||||
|
||||
.smart-suggestions {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 gap-spacing-xs;
|
||||
}
|
||||
|
||||
.smart-suggestion {
|
||||
@apply flex items-center gap-spacing-sm p-spacing-sm;
|
||||
@apply bg-surface-primary border border-border-subtle rounded-radius-lg;
|
||||
@apply transition-all duration-micro hover:shadow-elevation-soft hover:scale-[1.02];
|
||||
@apply cursor-pointer text-left;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
@apply block font-medium text-text-primary text-body-sm;
|
||||
}
|
||||
|
||||
.suggestion-desc {
|
||||
@apply block text-label-xs text-text-secondary;
|
||||
}
|
||||
|
||||
.suggestion-action {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.plus-icon {
|
||||
@apply w-5 h-5 text-primary-500 transition-transform duration-micro group-hover:scale-110;
|
||||
}
|
||||
|
||||
.quick-tips {
|
||||
@apply flex items-center justify-center py-spacing-xs;
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
@apply flex items-center gap-spacing-xs text-label-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
@apply w-4 h-4 text-primary-400;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
@apply transition-opacity duration-medium;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
@apply transition-all duration-micro ease-medium;
|
||||
}
|
||||
|
||||
.dropdown-enter-from {
|
||||
@apply opacity-0 scale-95 translate-y-2;
|
||||
}
|
||||
|
||||
.dropdown-leave-to {
|
||||
@apply opacity-0 scale-95 translate-y-2;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
@apply transition-all duration-medium ease-medium;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
@apply opacity-0 max-h-0 overflow-hidden;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
@apply opacity-100 max-h-96;
|
||||
}
|
||||
</style>
|
227
fe/src/components/ReceiptScannerModal.vue
Normal file
227
fe/src/components/ReceiptScannerModal.vue
Normal file
@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<Dialog v-model="open" size="md" class="z-50">
|
||||
<div class="receipt-scanner-modal">
|
||||
<header class="modal-header">
|
||||
<BaseIcon name="heroicons:receipt-percent" class="header-icon" />
|
||||
<Heading :level="3">Scan Receipt</Heading>
|
||||
<Button variant="ghost" size="sm" @click="closeModal" class="close-btn">
|
||||
<BaseIcon name="heroicons:x-mark" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main class="modal-content">
|
||||
<div v-if="!imagePreview && !loading" class="upload-section">
|
||||
<label class="upload-label" @dragover.prevent @drop.prevent="handleDrop">
|
||||
<BaseIcon name="heroicons:photo" class="upload-icon" />
|
||||
<span>Drag & drop or click to upload a receipt image (JPG, PNG, WEBP)</span>
|
||||
<input type="file" accept="image/*" @change="handleFileChange" hidden />
|
||||
</label>
|
||||
<p class="upload-help">Max size: 5MB. Only images are supported.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="imagePreview && !loading" class="preview-section">
|
||||
<img :src="imagePreview" alt="Receipt Preview" class="receipt-preview" />
|
||||
<Button variant="outline" size="sm" @click="removeImage" class="remove-btn">
|
||||
<BaseIcon name="heroicons:trash" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-section">
|
||||
<Spinner size="lg" />
|
||||
<p class="loading-text">Extracting items from your receipt…</p>
|
||||
</div>
|
||||
|
||||
<div v-if="extractedItems.length > 0 && !loading" class="results-section">
|
||||
<h4 class="results-title">Extracted Items</h4>
|
||||
<div class="items-list">
|
||||
<label v-for="(item, idx) in extractedItems" :key="idx" class="item-row">
|
||||
<input type="checkbox" v-model="selectedItems" :value="item" />
|
||||
<span>{{ item }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" type="error" :message="error" class="mt-4" />
|
||||
</main>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<Button variant="outline" @click="closeModal">Cancel</Button>
|
||||
<Button color="primary" :disabled="selectedItems.length === 0" @click="addSelectedItems">
|
||||
<BaseIcon name="heroicons:plus-circle" />
|
||||
Add Selected Items
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Dialog, Heading, Button, Alert, Spinner } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { apiClient } from '@/services/api'
|
||||
|
||||
const open = defineModel<boolean>('modelValue', { default: false })
|
||||
const emit = defineEmits<{ (e: 'add', items: string[]): void }>()
|
||||
|
||||
const imageFile = ref<File | null>(null)
|
||||
const imagePreview = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const extractedItems = ref<string[]>([])
|
||||
const selectedItems = ref<string[]>([])
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = 'Only image files are supported.'
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
error.value = 'File is too large (max 5MB).'
|
||||
return
|
||||
}
|
||||
imageFile.value = file
|
||||
error.value = null
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
imagePreview.value = ev.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
extractedItems.value = []
|
||||
selectedItems.value = []
|
||||
uploadImage()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) {
|
||||
handleFileChange({ target: { files: [file] } } as any)
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
imageFile.value = null
|
||||
imagePreview.value = null
|
||||
extractedItems.value = []
|
||||
selectedItems.value = []
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function uploadImage() {
|
||||
if (!imageFile.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
extractedItems.value = []
|
||||
selectedItems.value = []
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image_file', imageFile.value)
|
||||
const { data } = await apiClient.post('/api/v1/ocr/extract-items', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
extractedItems.value = data.extracted_items || []
|
||||
selectedItems.value = [...extractedItems.value]
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || 'Failed to extract items from receipt.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addSelectedItems() {
|
||||
emit('add', selectedItems.value)
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
open.value = false
|
||||
removeImage()
|
||||
error.value = null
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (!val) removeImage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.receipt-scanner-modal {
|
||||
@apply bg-surface-primary rounded-lg w-full max-w-md overflow-hidden flex flex-col;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex items-center gap-4 p-6 border-b border-border-primary bg-surface-soft relative;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@apply w-8 h-8 text-primary-500;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@apply absolute right-4 top-4;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply flex-1 p-6 space-y-6;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
@apply flex flex-col items-center justify-center gap-4;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
@apply flex flex-col items-center gap-2 p-8 border-2 border-dashed border-border-secondary rounded-lg cursor-pointer transition-colors duration-200 hover:border-primary-300 hover:bg-surface-soft;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
@apply w-12 h-12 text-text-tertiary;
|
||||
}
|
||||
|
||||
.upload-help {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
@apply flex flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.receipt-preview {
|
||||
@apply w-64 max-h-64 object-contain rounded-lg shadow;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
@apply flex flex-col items-center gap-4;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
@apply text-lg font-semibold text-text-primary mb-2;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
@apply flex items-center gap-3 p-2 rounded hover:bg-surface-soft transition-colors duration-150;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply flex items-center justify-end gap-4 p-6 border-t border-border-primary bg-surface-soft;
|
||||
}
|
||||
</style>
|
339
fe/src/components/SmartShoppingItem.vue
Normal file
339
fe/src/components/SmartShoppingItem.vue
Normal file
@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div class="smart-shopping-item" :class="itemStateClasses">
|
||||
<div class="item-content">
|
||||
<!-- Drag Handle -->
|
||||
<div v-if="isOnline && !isCompleted" class="drag-handle">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="5" r="1"></circle>
|
||||
<circle cx="9" cy="19" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="5" r="1"></circle>
|
||||
<circle cx="15" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox and Item Info -->
|
||||
<div class="item-main">
|
||||
<div class="checkbox-section">
|
||||
<input type="checkbox" :checked="item.is_complete" @change="handleToggleComplete"
|
||||
:disabled="!canToggle" class="item-checkbox" />
|
||||
<div class="item-info">
|
||||
<h4 class="item-name">{{ item.name }}</h4>
|
||||
<p v-if="item.quantity" class="item-quantity">Qty: {{ item.quantity }}</p>
|
||||
|
||||
<!-- Claim Status -->
|
||||
<div v-if="claimStatusText" class="claim-status">
|
||||
<span class="claim-text">{{ claimStatusText }}</span>
|
||||
<span v-if="claimTimeText" class="claim-time">{{ claimTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="item-actions">
|
||||
<!-- Claim/Unclaim Buttons -->
|
||||
<Button v-if="canClaim" variant="outline" size="sm" @click="$emit('claim', item)" :disabled="!isOnline"
|
||||
class="claim-btn">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Claim
|
||||
</Button>
|
||||
|
||||
<Button v-if="canUnclaim" variant="outline" size="sm" @click="$emit('unclaim', item)"
|
||||
:disabled="!isOnline" class="unclaim-btn">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Unclaim
|
||||
</Button>
|
||||
|
||||
<!-- Price Input -->
|
||||
<div v-if="isCompleted" class="price-section">
|
||||
<input type="number" :value="item.price || ''" @input="handlePriceUpdate" placeholder="$0.00"
|
||||
step="0.01" min="0" class="price-input" />
|
||||
</div>
|
||||
|
||||
<!-- Edit Button -->
|
||||
<Button variant="ghost" size="sm" @click="$emit('edit', item)" class="edit-btn" aria-label="Edit item">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator for Claimed Items -->
|
||||
<div v-if="isClaimedByMe && !isCompleted" class="claimed-indicator">
|
||||
<div class="claimed-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Item } from '@/types/item'
|
||||
import type { List } from '@/types/list'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<Item>,
|
||||
required: true
|
||||
},
|
||||
list: {
|
||||
type: Object as PropType<List>,
|
||||
required: true
|
||||
},
|
||||
isOnline: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isClaimedByMe: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isClaimedByOthers: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isCompleted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'claim',
|
||||
'unclaim',
|
||||
'complete',
|
||||
'update-price',
|
||||
'edit'
|
||||
])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const currentUser = computed(() => authStore.user)
|
||||
|
||||
// Computed properties
|
||||
const itemStateClasses = computed(() => ({
|
||||
'is-completed': props.item.is_complete,
|
||||
'is-claimed-by-me': props.isClaimedByMe,
|
||||
'is-claimed-by-others': props.isClaimedByOthers,
|
||||
'is-readonly': props.readonly
|
||||
}))
|
||||
|
||||
const canClaim = computed(() => {
|
||||
return (
|
||||
props.list.group_id &&
|
||||
!props.item.is_complete &&
|
||||
!props.item.claimed_by_user_id &&
|
||||
!props.readonly &&
|
||||
props.isOnline
|
||||
)
|
||||
})
|
||||
|
||||
const canUnclaim = computed(() => {
|
||||
return (
|
||||
props.item.claimed_by_user_id === currentUser.value?.id &&
|
||||
!props.readonly &&
|
||||
props.isOnline
|
||||
)
|
||||
})
|
||||
|
||||
const canToggle = computed(() => {
|
||||
return (
|
||||
props.isOnline &&
|
||||
!props.readonly &&
|
||||
(props.isClaimedByMe || !props.list.group_id || !props.item.claimed_by_user_id)
|
||||
)
|
||||
})
|
||||
|
||||
const claimStatusText = computed(() => {
|
||||
if (!props.item.claimed_by_user_id) return ''
|
||||
|
||||
const claimer = props.item.claimed_by_user_id === currentUser.value?.id
|
||||
? 'You'
|
||||
: props.item.claimed_by_user?.name || 'Someone'
|
||||
|
||||
return `${claimer} claimed this`
|
||||
})
|
||||
|
||||
const claimTimeText = computed(() => {
|
||||
if (!props.item.claimed_at) return ''
|
||||
|
||||
try {
|
||||
return formatDistanceToNow(new Date(props.item.claimed_at), { addSuffix: true })
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleToggleComplete = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('complete', props.item, target.checked)
|
||||
}
|
||||
|
||||
const handlePriceUpdate = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const price = parseFloat(target.value) || 0
|
||||
emit('update-price', props.item, price)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-shopping-item {
|
||||
@apply relative bg-white transition-all duration-200 ease-out;
|
||||
}
|
||||
|
||||
.smart-shopping-item:hover {
|
||||
@apply bg-neutral-50;
|
||||
}
|
||||
|
||||
.smart-shopping-item.is-completed {
|
||||
@apply opacity-60;
|
||||
}
|
||||
|
||||
.smart-shopping-item.is-claimed-by-me {
|
||||
@apply bg-blue-50 border-l-4 border-l-blue-500;
|
||||
}
|
||||
|
||||
.smart-shopping-item.is-claimed-by-others {
|
||||
@apply bg-amber-50 border-l-4 border-l-amber-500;
|
||||
}
|
||||
|
||||
.smart-shopping-item.is-readonly {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
@apply flex items-center p-4 gap-3;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
@apply text-neutral-400 cursor-move;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.checkbox-section {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.item-checkbox {
|
||||
@apply w-5 h-5 rounded border-2 border-neutral-300 text-blue-600 focus:ring-blue-500 focus:ring-2 focus:ring-offset-2;
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.item-checkbox:checked {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
@apply font-medium text-neutral-900 mb-1;
|
||||
}
|
||||
|
||||
.is-completed .item-name {
|
||||
@apply line-through text-neutral-500;
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
@apply text-sm text-neutral-600 mb-1;
|
||||
}
|
||||
|
||||
.claim-status {
|
||||
@apply flex items-center gap-2 text-xs;
|
||||
}
|
||||
|
||||
.claim-text {
|
||||
@apply font-medium text-neutral-700;
|
||||
}
|
||||
|
||||
.is-claimed-by-me .claim-text {
|
||||
@apply text-blue-700;
|
||||
}
|
||||
|
||||
.is-claimed-by-others .claim-text {
|
||||
@apply text-amber-700;
|
||||
}
|
||||
|
||||
.claim-time {
|
||||
@apply text-neutral-500;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.claim-btn,
|
||||
.unclaim-btn {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
.claim-btn {
|
||||
@apply border-blue-300 text-blue-700 hover:bg-blue-50;
|
||||
}
|
||||
|
||||
.unclaim-btn {
|
||||
@apply border-amber-300 text-amber-700 hover:bg-amber-50;
|
||||
}
|
||||
|
||||
.price-section {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.price-input {
|
||||
@apply w-20 px-2 py-1 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
@apply text-neutral-500 hover:text-neutral-700;
|
||||
}
|
||||
|
||||
.claimed-indicator {
|
||||
@apply absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-blue-600;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.claimed-bar {
|
||||
@apply h-full w-full bg-blue-500 animate-pulse;
|
||||
}
|
||||
|
||||
/* Animation for claim status changes */
|
||||
.smart-shopping-item {
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
@keyframes claim-highlight {
|
||||
0% {
|
||||
@apply bg-blue-100;
|
||||
}
|
||||
|
||||
100% {
|
||||
@apply bg-blue-50;
|
||||
}
|
||||
}
|
||||
|
||||
.is-claimed-by-me {
|
||||
animation: claim-highlight 0.5s ease-out;
|
||||
}
|
||||
</style>
|
828
fe/src/components/SmartShoppingList.vue
Normal file
828
fe/src/components/SmartShoppingList.vue
Normal file
@ -0,0 +1,828 @@
|
||||
<template>
|
||||
<div class="smart-shopping-list">
|
||||
<!-- List Header with Smart Actions -->
|
||||
<div class="list-header">
|
||||
<div class="header-info">
|
||||
<h2 class="list-title">{{ list.name }}</h2>
|
||||
<div class="list-stats">
|
||||
<span class="item-count">{{ incompletedItems.length }} items</span>
|
||||
<span v-if="claimedByOthersCount > 0" class="claimed-indicator">
|
||||
{{ claimedByOthersCount }} claimed by others
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="header-actions">
|
||||
<Button v-if="hasCompletedItemsWithoutPrices" variant="soft" size="sm" @click="showReceiptScanner"
|
||||
class="receipt-scanner-btn">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Scan Receipt
|
||||
</Button>
|
||||
|
||||
<Button v-if="canCreateExpense" variant="primary" size="sm" @click="showExpensePrompt"
|
||||
class="create-expense-btn">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Expense
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict Alerts -->
|
||||
<TransitionGroup name="alert" tag="div" class="space-y-3 mb-4">
|
||||
<Alert v-for="conflict in activeConflicts" :key="conflict.id" type="warning" :message="conflict.message"
|
||||
class="conflict-alert" @dismiss="dismissConflict(conflict.id)" />
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Smart Shopping Items -->
|
||||
<div class="shopping-items">
|
||||
<!-- Unclaimed Items -->
|
||||
<div v-if="unclaimedItems.length > 0" class="item-group">
|
||||
<h3 class="group-title">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
Available to Shop ({{ unclaimedItems.length }})
|
||||
</h3>
|
||||
<div class="items-list">
|
||||
<SmartShoppingItem v-for="item in unclaimedItems" :key="item.id" :item="item" :list="list"
|
||||
:is-online="isOnline" @claim="handleClaimItem" @complete="handleCompleteItem"
|
||||
@update-price="handleUpdatePrice" @edit="$emit('edit-item', item)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Claimed Items -->
|
||||
<div v-if="myClaimedItems.length > 0" class="item-group">
|
||||
<h3 class="group-title claimed-by-me">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
My Items ({{ myClaimedItems.length }})
|
||||
</h3>
|
||||
<div class="items-list">
|
||||
<SmartShoppingItem v-for="item in myClaimedItems" :key="item.id" :item="item" :list="list"
|
||||
:is-online="isOnline" :is-claimed-by-me="true" @unclaim="handleUnclaimItem"
|
||||
@complete="handleCompleteItem" @update-price="handleUpdatePrice"
|
||||
@edit="$emit('edit-item', item)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Others' Claimed Items -->
|
||||
<div v-if="othersClaimedItems.length > 0" class="item-group">
|
||||
<h3 class="group-title claimed-by-others">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
Claimed by Others ({{ othersClaimedItems.length }})
|
||||
</h3>
|
||||
<div class="items-list">
|
||||
<SmartShoppingItem v-for="item in othersClaimedItems" :key="item.id" :item="item" :list="list"
|
||||
:is-online="isOnline" :is-claimed-by-others="true" @edit="$emit('edit-item', item)" readonly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Items -->
|
||||
<div v-if="completedItems.length > 0" class="item-group">
|
||||
<h3 class="group-title completed">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Completed ({{ completedItems.length }})
|
||||
</h3>
|
||||
<div class="items-list completed-items">
|
||||
<SmartShoppingItem v-for="item in completedItems" :key="item.id" :item="item" :list="list"
|
||||
:is-online="isOnline" :is-completed="true" @update-price="handleUpdatePrice"
|
||||
@edit="$emit('edit-item', item)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Undo Toast -->
|
||||
<Transition name="undo-toast">
|
||||
<div v-if="undoAction" class="undo-toast">
|
||||
<div class="undo-content">
|
||||
<span class="undo-message">{{ undoAction.message }}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" @click="performUndo" class="undo-button">
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Receipt Scanner Modal -->
|
||||
<Dialog v-model="showReceiptScanner" title="Scan Receipt">
|
||||
<div class="receipt-scanner">
|
||||
<div class="scanner-area">
|
||||
<input ref="fileInput" type="file" accept="image/*" capture="environment"
|
||||
@change="handleReceiptUpload" class="hidden" />
|
||||
<Button @click="triggerFileUpload" variant="outline" class="upload-btn">
|
||||
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Choose Receipt Image
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="scanningReceipt" class="scanning-status">
|
||||
<Spinner size="sm" />
|
||||
<span>Scanning receipt...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="scannedData" class="scanned-results">
|
||||
<h4 class="results-title">Detected Items & Prices:</h4>
|
||||
<div class="detected-items">
|
||||
<div v-for="(detectedItem, index) in scannedData.items" :key="index" class="detected-item">
|
||||
<span class="item-name">{{ detectedItem.name }}</span>
|
||||
<span class="item-price">${{ detectedItem.price.toFixed(2) }}</span>
|
||||
<Button size="sm" variant="outline" @click="applyScannedPrice(detectedItem)">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Expense Creation Prompt -->
|
||||
<Dialog v-model="showExpensePrompt" title="Create Expense from Shopping">
|
||||
<div class="expense-prompt">
|
||||
<p class="prompt-message">
|
||||
You have {{ completedItemsWithPrices.length }} completed items with prices totaling
|
||||
<strong>${{ totalCompletedValue.toFixed(2) }}</strong>.
|
||||
Would you like to create an expense for this shopping trip?
|
||||
</p>
|
||||
|
||||
<div class="expense-options">
|
||||
<div class="option-card" @click="createExpenseType = 'equal'">
|
||||
<input type="radio" id="equal-split" v-model="createExpenseType" value="equal"
|
||||
class="sr-only" />
|
||||
<label for="equal-split" class="option-label">
|
||||
<h4>Equal Split</h4>
|
||||
<p>Split total amount equally among all group members</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="option-card" @click="createExpenseType = 'item-based'">
|
||||
<input type="radio" id="item-split" v-model="createExpenseType" value="item-based"
|
||||
class="sr-only" />
|
||||
<label for="item-split" class="option-label">
|
||||
<h4>Item-Based Split</h4>
|
||||
<p>Each person pays for items they added to the list</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-actions">
|
||||
<Button variant="outline" @click="showExpensePrompt = false">
|
||||
Not Now
|
||||
</Button>
|
||||
<Button variant="primary" @click="createExpenseFromShopping">
|
||||
Create Expense
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { useListsStore } from '@/stores/listsStore'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import type { Item } from '@/types/item'
|
||||
import type { List } from '@/types/list'
|
||||
|
||||
// UI Components
|
||||
import { Button, Alert, Dialog, Spinner } from '@/components/ui'
|
||||
import SmartShoppingItem from './SmartShoppingItem.vue'
|
||||
|
||||
interface UndoAction {
|
||||
id: string
|
||||
type: 'claim' | 'unclaim' | 'complete' | 'price-update'
|
||||
message: string
|
||||
originalState: any
|
||||
item: Item
|
||||
timeRemaining: number
|
||||
}
|
||||
|
||||
interface Conflict {
|
||||
id: string
|
||||
message: string
|
||||
type: 'simultaneous-claim' | 'concurrent-edit' | 'price-conflict'
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object as PropType<List>,
|
||||
required: true
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<Item[]>,
|
||||
required: true
|
||||
},
|
||||
isOnline: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'edit-item',
|
||||
'create-expense'
|
||||
])
|
||||
|
||||
// Stores
|
||||
const listsStore = useListsStore()
|
||||
const authStore = useAuthStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// Reactive state
|
||||
const undoAction = ref<UndoAction | null>(null)
|
||||
const undoProgress = ref(100)
|
||||
const activeConflicts = ref<Conflict[]>([])
|
||||
const showReceiptScanner = ref(false)
|
||||
const showExpensePrompt = ref(false)
|
||||
const scanningReceipt = ref(false)
|
||||
const scannedData = ref(null)
|
||||
const createExpenseType = ref<'equal' | 'item-based'>('equal')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
// Computed properties for intelligent item grouping
|
||||
const currentUser = computed(() => authStore.user)
|
||||
|
||||
const incompletedItems = computed(() =>
|
||||
props.items.filter(item => !item.is_complete)
|
||||
)
|
||||
|
||||
const completedItems = computed(() =>
|
||||
props.items.filter(item => item.is_complete)
|
||||
)
|
||||
|
||||
const unclaimedItems = computed(() =>
|
||||
incompletedItems.value.filter(item => !item.claimed_by_user_id)
|
||||
)
|
||||
|
||||
const myClaimedItems = computed(() =>
|
||||
incompletedItems.value.filter(item =>
|
||||
item.claimed_by_user_id === currentUser.value?.id
|
||||
)
|
||||
)
|
||||
|
||||
const othersClaimedItems = computed(() =>
|
||||
incompletedItems.value.filter(item =>
|
||||
item.claimed_by_user_id && item.claimed_by_user_id !== currentUser.value?.id
|
||||
)
|
||||
)
|
||||
|
||||
const claimedByOthersCount = computed(() => othersClaimedItems.value.length)
|
||||
|
||||
const completedItemsWithPrices = computed(() =>
|
||||
completedItems.value.filter(item => {
|
||||
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
|
||||
return price > 0
|
||||
})
|
||||
)
|
||||
|
||||
const hasCompletedItemsWithoutPrices = computed(() =>
|
||||
completedItems.value.some(item => {
|
||||
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
|
||||
return price <= 0
|
||||
})
|
||||
)
|
||||
|
||||
const canCreateExpense = computed(() =>
|
||||
completedItemsWithPrices.value.length > 0 && props.list.group_id
|
||||
)
|
||||
|
||||
const totalCompletedValue = computed(() =>
|
||||
completedItemsWithPrices.value.reduce((sum, item) => {
|
||||
const price = typeof item.price === 'string' ? parseFloat(item.price) : (item.price || 0)
|
||||
return sum + price
|
||||
}, 0)
|
||||
)
|
||||
|
||||
// Item action handlers with optimistic updates and undo functionality
|
||||
const handleClaimItem = async (item: Item) => {
|
||||
if (!props.isOnline) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Cannot claim items while offline'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for simultaneous claims
|
||||
if (item.claimed_by_user_id) {
|
||||
addConflict({
|
||||
id: `claim-conflict-${item.id}`,
|
||||
type: 'simultaneous-claim',
|
||||
message: `"${item.name}" was just claimed by ${item.claimed_by_user?.name}. Refresh to see latest state.`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const originalState = { ...item }
|
||||
|
||||
// Optimistic update
|
||||
item.claimed_by_user_id = currentUser.value?.id ? Number(currentUser.value.id) : null
|
||||
item.claimed_by_user = currentUser.value || null
|
||||
item.claimed_at = new Date().toISOString()
|
||||
|
||||
try {
|
||||
await listsStore.claimItem(item.id)
|
||||
|
||||
// Add undo action
|
||||
addUndoAction({
|
||||
id: `claim-${item.id}`,
|
||||
type: 'claim',
|
||||
message: `Claimed "${item.name}"`,
|
||||
originalState,
|
||||
item,
|
||||
timeRemaining: 10000
|
||||
})
|
||||
|
||||
// Haptic feedback
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(50)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Rollback optimistic update
|
||||
Object.assign(item, originalState)
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to claim item. Please try again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnclaimItem = async (item: Item) => {
|
||||
const originalState = { ...item }
|
||||
|
||||
// Optimistic update
|
||||
item.claimed_by_user_id = null
|
||||
item.claimed_by_user = null
|
||||
item.claimed_at = null
|
||||
|
||||
try {
|
||||
await listsStore.unclaimItem(item.id)
|
||||
|
||||
addUndoAction({
|
||||
id: `unclaim-${item.id}`,
|
||||
type: 'unclaim',
|
||||
message: `Unclaimed "${item.name}"`,
|
||||
originalState,
|
||||
item,
|
||||
timeRemaining: 10000
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
Object.assign(item, originalState)
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to unclaim item. Please try again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompleteItem = async (item: Item, completed: boolean) => {
|
||||
const originalState = { ...item }
|
||||
|
||||
// Optimistic update
|
||||
item.is_complete = completed
|
||||
|
||||
try {
|
||||
// Call the actual API through the store
|
||||
await listsStore.updateItem(item.id, { is_complete: completed })
|
||||
|
||||
if (completed) {
|
||||
addUndoAction({
|
||||
id: `complete-${item.id}`,
|
||||
type: 'complete',
|
||||
message: `Completed "${item.name}"`,
|
||||
originalState,
|
||||
item,
|
||||
timeRemaining: 10000
|
||||
})
|
||||
|
||||
// Celebration feedback
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate([100, 50, 100])
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
Object.assign(item, originalState)
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to update item. Please try again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatePrice = async (item: Item, price: number) => {
|
||||
const originalState = { ...item }
|
||||
|
||||
// Optimistic update
|
||||
item.price = price
|
||||
|
||||
try {
|
||||
await listsStore.updateItem(item.id, { price })
|
||||
|
||||
addUndoAction({
|
||||
id: `price-${item.id}`,
|
||||
type: 'price-update',
|
||||
message: `Updated price for "${item.name}" to $${price.toFixed(2)}`,
|
||||
originalState,
|
||||
item,
|
||||
timeRemaining: 10000
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
Object.assign(item, originalState)
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to update price. Please try again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Undo system
|
||||
const addUndoAction = (action: UndoAction) => {
|
||||
// Clear any existing undo action
|
||||
if (undoAction.value) {
|
||||
clearUndoTimer()
|
||||
}
|
||||
|
||||
undoAction.value = action
|
||||
undoProgress.value = 100
|
||||
|
||||
// Start countdown timer
|
||||
const startTime = Date.now()
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, action.timeRemaining - elapsed)
|
||||
undoProgress.value = (remaining / action.timeRemaining) * 100
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer)
|
||||
undoAction.value = null
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// Auto-clear after timeout
|
||||
setTimeout(() => {
|
||||
if (undoAction.value?.id === action.id) {
|
||||
undoAction.value = null
|
||||
}
|
||||
}, action.timeRemaining)
|
||||
}
|
||||
|
||||
const performUndo = async () => {
|
||||
if (!undoAction.value) return
|
||||
|
||||
const action = undoAction.value
|
||||
undoAction.value = null
|
||||
|
||||
try {
|
||||
// Restore original state
|
||||
Object.assign(action.item, action.originalState)
|
||||
|
||||
// Make the API call to revert
|
||||
switch (action.type) {
|
||||
case 'claim':
|
||||
await listsStore.unclaimItem(action.item.id)
|
||||
break
|
||||
case 'unclaim':
|
||||
await listsStore.claimItem(action.item.id)
|
||||
break
|
||||
case 'complete':
|
||||
await listsStore.updateItem(action.item.id, { is_complete: action.originalState.is_complete })
|
||||
break
|
||||
case 'price-update':
|
||||
await listsStore.updateItem(action.item.id, { price: action.originalState.price })
|
||||
break
|
||||
}
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Action undone successfully'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to undo action. Please try again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearUndoTimer = () => {
|
||||
undoAction.value = null
|
||||
}
|
||||
|
||||
// Conflict management
|
||||
const addConflict = (conflict: Conflict) => {
|
||||
activeConflicts.value.push(conflict)
|
||||
|
||||
// Auto-dismiss after 10 seconds
|
||||
setTimeout(() => {
|
||||
dismissConflict(conflict.id)
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const dismissConflict = (conflictId: string) => {
|
||||
activeConflicts.value = activeConflicts.value.filter(c => c.id !== conflictId)
|
||||
}
|
||||
|
||||
// Receipt scanning
|
||||
const openReceiptScanner = () => {
|
||||
showReceiptScanner.value = true
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleReceiptUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
|
||||
scanningReceipt.value = true
|
||||
|
||||
try {
|
||||
// This would integrate with Tesseract.js or a backend OCR service
|
||||
// For now, we'll simulate the scanning process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Mock scanned data - in reality this would come from OCR
|
||||
scannedData.value = {
|
||||
items: [
|
||||
{ name: 'Milk', price: 4.99 },
|
||||
{ name: 'Bread', price: 3.50 },
|
||||
{ name: 'Eggs', price: 6.99 }
|
||||
]
|
||||
} as any
|
||||
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to scan receipt. Please try again.'
|
||||
})
|
||||
} finally {
|
||||
scanningReceipt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyScannedPrice = (detectedItem: any) => {
|
||||
// Find matching item in the list and apply the price
|
||||
const matchingItem = completedItems.value.find(item =>
|
||||
item.name.toLowerCase().includes(detectedItem.name.toLowerCase()) ||
|
||||
detectedItem.name.toLowerCase().includes(item.name.toLowerCase())
|
||||
)
|
||||
|
||||
if (matchingItem) {
|
||||
handleUpdatePrice(matchingItem, detectedItem.price)
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `Applied $${detectedItem.price.toFixed(2)} to "${matchingItem.name}"`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Expense creation
|
||||
const promptExpenseCreation = () => {
|
||||
showExpensePrompt.value = true
|
||||
}
|
||||
|
||||
const createExpenseFromShopping = () => {
|
||||
emit('create-expense', {
|
||||
type: createExpenseType.value,
|
||||
items: completedItemsWithPrices.value,
|
||||
total: totalCompletedValue.value
|
||||
})
|
||||
|
||||
showExpensePrompt.value = false
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Expense creation initiated'
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Connect to WebSocket for real-time updates if online
|
||||
if (props.isOnline && props.list.id) {
|
||||
listsStore.connectWebSocket(props.list.id, authStore.token)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
listsStore.disconnectWebSocket()
|
||||
clearUndoTimer()
|
||||
})
|
||||
|
||||
// Watch for online status changes
|
||||
watch(() => props.isOnline, (isOnline) => {
|
||||
if (isOnline && props.list.id) {
|
||||
listsStore.connectWebSocket(props.list.id, authStore.token)
|
||||
} else {
|
||||
listsStore.disconnectWebSocket()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-shopping-list {
|
||||
@apply max-w-4xl mx-auto space-y-6;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
@apply flex items-start justify-between p-4 bg-white rounded-lg shadow-soft border border-neutral-200;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
@apply text-xl font-semibold text-neutral-900 mb-1;
|
||||
}
|
||||
|
||||
.list-stats {
|
||||
@apply flex items-center space-x-4 text-sm text-neutral-600;
|
||||
}
|
||||
|
||||
.claimed-indicator {
|
||||
@apply px-2 py-1 bg-amber-100 text-amber-800 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center space-x-3;
|
||||
}
|
||||
|
||||
.item-group {
|
||||
@apply bg-white rounded-lg shadow-soft border border-neutral-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
@apply flex items-center px-4 py-3 bg-neutral-50 border-b border-neutral-200 font-medium text-neutral-900;
|
||||
}
|
||||
|
||||
.group-title.claimed-by-me {
|
||||
@apply bg-blue-50 text-blue-900;
|
||||
}
|
||||
|
||||
.group-title.claimed-by-others {
|
||||
@apply bg-amber-50 text-amber-900;
|
||||
}
|
||||
|
||||
.group-title.completed {
|
||||
@apply bg-green-50 text-green-900;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
@apply divide-y divide-neutral-200;
|
||||
}
|
||||
|
||||
.completed-items {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
.conflict-alert {
|
||||
@apply border-l-4 border-l-amber-500;
|
||||
}
|
||||
|
||||
.undo-toast {
|
||||
@apply fixed bottom-4 right-4 z-50 bg-neutral-900 text-white rounded-lg shadow-floating p-4 min-w-80;
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.undo-content {
|
||||
@apply flex-1 relative;
|
||||
}
|
||||
|
||||
.undo-message {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
.undo-progress {
|
||||
@apply absolute bottom-0 left-0 h-0.5 bg-blue-500 transition-all duration-50 ease-linear;
|
||||
}
|
||||
|
||||
.undo-button {
|
||||
@apply ml-4 text-blue-400 hover:text-blue-300;
|
||||
}
|
||||
|
||||
.receipt-scanner {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.scanner-area {
|
||||
@apply flex flex-col items-center justify-center p-8 border-2 border-dashed border-neutral-300 rounded-lg;
|
||||
}
|
||||
|
||||
.scanning-status {
|
||||
@apply flex items-center justify-center space-x-3 p-4 bg-blue-50 rounded-lg;
|
||||
}
|
||||
|
||||
.scanned-results {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
@apply font-semibold text-neutral-900;
|
||||
}
|
||||
|
||||
.detected-items {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.detected-item {
|
||||
@apply flex items-center justify-between p-3 bg-neutral-50 rounded-lg;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
@apply font-medium text-neutral-900;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
@apply text-green-600 font-mono;
|
||||
}
|
||||
|
||||
.expense-prompt {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.prompt-message {
|
||||
@apply text-neutral-700 leading-relaxed;
|
||||
}
|
||||
|
||||
.expense-options {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.option-card {
|
||||
@apply border border-neutral-200 rounded-lg p-4 cursor-pointer transition-all duration-200;
|
||||
@apply hover:border-blue-300 hover:bg-blue-50;
|
||||
}
|
||||
|
||||
.option-card:has(input:checked) {
|
||||
@apply border-blue-500 bg-blue-50;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.option-label h4 {
|
||||
@apply font-semibold text-neutral-900 mb-1;
|
||||
}
|
||||
|
||||
.option-label p {
|
||||
@apply text-sm text-neutral-600;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
@apply flex justify-end space-x-3;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.alert-enter-active,
|
||||
.alert-leave-active {
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.alert-enter-from,
|
||||
.alert-leave-to {
|
||||
@apply opacity-0 transform translate-y-2;
|
||||
}
|
||||
|
||||
.undo-toast-enter-active,
|
||||
.undo-toast-leave-active {
|
||||
@apply transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.undo-toast-enter-from,
|
||||
.undo-toast-leave-to {
|
||||
@apply opacity-0 transform translate-x-full;
|
||||
}
|
||||
</style>
|
19
fe/src/components/__tests__/InviteManager.spec.ts
Normal file
19
fe/src/components/__tests__/InviteManager.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import InviteManager from '../InviteManager.vue'
|
||||
|
||||
// Mock clipboard
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
})
|
||||
|
||||
describe('InviteManager.vue', () => {
|
||||
it('shows generate button when no invite', () => {
|
||||
const wrapper = mount(InviteManager, {
|
||||
props: { groupId: 1 },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Generate Invite Code')
|
||||
})
|
||||
})
|
16
fe/src/components/__tests__/ReceiptScannerModal.spec.ts
Normal file
16
fe/src/components/__tests__/ReceiptScannerModal.spec.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import ReceiptScannerModal from '../ReceiptScannerModal.vue'
|
||||
|
||||
describe('ReceiptScannerModal.vue', () => {
|
||||
it('rejects non-image files', async () => {
|
||||
const wrapper = mount(ReceiptScannerModal)
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
const fakeFile = new File(['test'], 'test.pdf', { type: 'application/pdf' })
|
||||
await fileInput.trigger('change', { target: { files: [fakeFile] } })
|
||||
|
||||
expect(wrapper.text()).toContain('Only image files are supported')
|
||||
})
|
||||
})
|
@ -1,35 +1,287 @@
|
||||
<template>
|
||||
<div class="rounded-lg bg-white dark:bg-dark shadow">
|
||||
<h2 class="font-bold p-4 border-b border-gray-200 dark:border-neutral-700">Activity Feed</h2>
|
||||
<div v-if="store.isLoading && store.activities.length === 0" class="p-4 text-center text-gray-500">
|
||||
Loading activity...
|
||||
<div class="activity-feed">
|
||||
<!-- Feed Header -->
|
||||
<div class="feed-header">
|
||||
<div class="header-content">
|
||||
<h2 class="feed-title">Recent Activity</h2>
|
||||
<div class="header-stats">
|
||||
<span v-if="!store.isLoading" class="activity-count">
|
||||
{{ totalActivitiesText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button v-if="store.activities.length > 0" class="view-all-btn" @click="handleViewAll"
|
||||
aria-label="View all activities">
|
||||
<span class="material-icons">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.error" class="p-4 text-center text-danger">
|
||||
{{ store.error }}
|
||||
|
||||
<!-- Feed Content -->
|
||||
<div class="feed-content">
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.isLoading && store.activities.length === 0" class="feed-loading">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner">
|
||||
<svg class="animate-spin h-6 w-6 text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25">
|
||||
</circle>
|
||||
<path fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
class="opacity-75"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="loading-text">Loading recent activity...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="store.error" class="feed-error">
|
||||
<div class="error-content">
|
||||
<span class="material-icons error-icon">error_outline</span>
|
||||
<p class="error-message">{{ store.error }}</p>
|
||||
<button class="retry-btn" @click="handleRetry">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Groups -->
|
||||
<div v-else-if="activityGroups.length > 0" class="activity-groups">
|
||||
<div v-for="group in activityGroups" :key="group.key" class="activity-group"
|
||||
:class="{ 'new-group': group.isNew }">
|
||||
<!-- Group Header -->
|
||||
<div class="group-header">
|
||||
<time class="group-time">{{ group.timeLabel }}</time>
|
||||
<div class="group-stats">
|
||||
<span class="group-count">{{ group.activities.length }} {{ group.activities.length === 1 ?
|
||||
'update' : 'updates' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Activities -->
|
||||
<div class="group-activities">
|
||||
<ActivityItem v-for="activity in group.activities" :key="activity.id" :activity="activity"
|
||||
:is-new="isNewActivity(activity)" @click="handleActivityClick(activity)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Sentinel -->
|
||||
<div ref="loadMoreSentinel" class="load-more-sentinel" :class="{ 'loading': store.isLoading }">
|
||||
<div v-if="store.isLoading" class="load-more-spinner">
|
||||
<svg class="animate-spin h-4 w-4 text-neutral-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25">
|
||||
</circle>
|
||||
<path fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
class="opacity-75"></path>
|
||||
</svg>
|
||||
<span class="load-more-text">Loading more...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="feed-empty">
|
||||
<div class="empty-content">
|
||||
<div class="empty-illustration">
|
||||
<span class="material-icons empty-icon">timeline</span>
|
||||
</div>
|
||||
<h3 class="empty-title">No activity yet</h3>
|
||||
<p class="empty-description">
|
||||
Activity from your household will appear here as members complete chores, add expenses, and
|
||||
update lists.
|
||||
</p>
|
||||
<div class="empty-actions">
|
||||
<button class="empty-action-btn primary" @click="handleCreateSomething">
|
||||
<span class="material-icons">add</span>
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.activities.length > 0" class="divide-y divide-gray-200 dark:divide-neutral-700 px-4">
|
||||
<ActivityItem v-for="activity in store.activities" :key="activity.id" :activity="activity" />
|
||||
<div ref="loadMoreSentinel"></div>
|
||||
</div>
|
||||
<div v-else class="p-4 text-center text-gray-500">
|
||||
No recent activity.
|
||||
|
||||
<!-- Social Proof Footer -->
|
||||
<div v-if="socialProofMessage" class="social-proof">
|
||||
<div class="social-proof-content">
|
||||
<span class="material-icons social-proof-icon">people</span>
|
||||
<p class="social-proof-text">{{ socialProofMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { formatDistanceToNow, isToday, isYesterday, format, startOfDay, isSameDay } from 'date-fns'
|
||||
import { useActivityStore } from '@/stores/activityStore'
|
||||
import { useGroupStore } from '@/stores/groupStore'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Activity } from '@/types/activity'
|
||||
import ActivityItem from './ActivityItem.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
maxHeight?: string
|
||||
showSocialProof?: boolean
|
||||
limitVisible?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxHeight: '400px',
|
||||
showSocialProof: true,
|
||||
limitVisible: 20,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'activity-click': [activity: Activity]
|
||||
'create-action': []
|
||||
}>()
|
||||
|
||||
// Store and router
|
||||
const store = useActivityStore()
|
||||
const groupStore = useGroupStore()
|
||||
const authStore = useAuthStore()
|
||||
const loadMoreSentinel = ref<HTMLDivElement | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// Refs
|
||||
const loadMoreSentinel = ref<HTMLDivElement | null>(null)
|
||||
const recentActivityIds = ref<Set<string>>(new Set())
|
||||
|
||||
// Activity grouping logic
|
||||
interface ActivityGroup {
|
||||
key: string
|
||||
timeLabel: string
|
||||
activities: Activity[]
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
const activityGroups = computed<ActivityGroup[]>(() => {
|
||||
const activities = store.activities.slice(0, props.limitVisible)
|
||||
if (!activities.length) return []
|
||||
|
||||
const groups: ActivityGroup[] = []
|
||||
const groupMap = new Map<string, Activity[]>()
|
||||
|
||||
// Group activities by day
|
||||
activities.forEach(activity => {
|
||||
const activityDate = new Date(activity.timestamp)
|
||||
let groupKey: string
|
||||
let timeLabel: string
|
||||
|
||||
if (isToday(activityDate)) {
|
||||
groupKey = 'today'
|
||||
timeLabel = 'Today'
|
||||
} else if (isYesterday(activityDate)) {
|
||||
groupKey = 'yesterday'
|
||||
timeLabel = 'Yesterday'
|
||||
} else {
|
||||
groupKey = format(activityDate, 'yyyy-MM-dd')
|
||||
timeLabel = format(activityDate, 'EEEE, MMM d')
|
||||
}
|
||||
|
||||
if (!groupMap.has(groupKey)) {
|
||||
groupMap.set(groupKey, [])
|
||||
}
|
||||
groupMap.get(groupKey)!.push(activity)
|
||||
})
|
||||
|
||||
// Convert to groups array and sort
|
||||
const sortedKeys = Array.from(groupMap.keys()).sort((a, b) => {
|
||||
if (a === 'today') return -1
|
||||
if (b === 'today') return 1
|
||||
if (a === 'yesterday') return -1
|
||||
if (b === 'yesterday') return 1
|
||||
return b.localeCompare(a) // Recent dates first
|
||||
})
|
||||
|
||||
sortedKeys.forEach(key => {
|
||||
const groupActivities = groupMap.get(key)!
|
||||
groups.push({
|
||||
key,
|
||||
timeLabel: key === 'today' ? 'Today' :
|
||||
key === 'yesterday' ? 'Yesterday' :
|
||||
format(new Date(groupActivities[0].timestamp), 'EEEE, MMM d'),
|
||||
activities: groupActivities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
),
|
||||
isNew: key === 'today' && hasNewActivities(groupActivities)
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const hasNewActivities = (activities: Activity[]): boolean => {
|
||||
return activities.some(activity => isNewActivity(activity))
|
||||
}
|
||||
|
||||
const isNewActivity = (activity: Activity): boolean => {
|
||||
const activityTime = new Date(activity.timestamp)
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
|
||||
return activityTime > fiveMinutesAgo
|
||||
}
|
||||
|
||||
const totalActivitiesText = computed(() => {
|
||||
const count = store.activities.length
|
||||
if (count === 0) return ''
|
||||
if (count === 1) return '1 recent update'
|
||||
return `${count} recent updates`
|
||||
})
|
||||
|
||||
const socialProofMessage = computed(() => {
|
||||
if (!props.showSocialProof || !store.activities.length) return ''
|
||||
|
||||
const recentCompletions = store.activities
|
||||
.filter(activity => activity.event_type.includes('completed'))
|
||||
.slice(0, 3)
|
||||
|
||||
if (recentCompletions.length === 0) {
|
||||
return 'Your household is getting organized! 🏠'
|
||||
}
|
||||
|
||||
if (recentCompletions.length === 1) {
|
||||
return `${recentCompletions[0].user.name || 'Someone'} is staying productive! 💪`
|
||||
}
|
||||
|
||||
return `${recentCompletions.length} completed tasks this week. Keep it up! 🎉`
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleActivityClick = (activity: Activity) => {
|
||||
emit('activity-click', activity)
|
||||
|
||||
// Auto-navigate based on activity type
|
||||
if (activity.event_type.includes('chore')) {
|
||||
router.push('/chores')
|
||||
} else if (activity.event_type.includes('expense')) {
|
||||
router.push('/expenses')
|
||||
} else if (activity.event_type.includes('item')) {
|
||||
router.push('/lists')
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewAll = () => {
|
||||
router.push('/activity')
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
if (groupStore.currentGroupId) {
|
||||
store.fetchActivities(groupStore.currentGroupId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateSomething = () => {
|
||||
emit('create-action')
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
store.fetchActivities(groupStore.currentGroupId)
|
||||
@ -51,6 +303,7 @@ onUnmounted(() => {
|
||||
store.disconnectWebSocket()
|
||||
})
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
useIntersectionObserver(
|
||||
loadMoreSentinel,
|
||||
([{ isIntersecting }]) => {
|
||||
@ -59,4 +312,239 @@ useIntersectionObserver(
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
// Track new activities for animations
|
||||
watch(() => store.activities, (newActivities, oldActivities) => {
|
||||
if (oldActivities && newActivities.length > oldActivities.length) {
|
||||
// New activities were added
|
||||
const newIds = newActivities
|
||||
.slice(0, newActivities.length - oldActivities.length)
|
||||
.map(activity => activity.id)
|
||||
|
||||
newIds.forEach(id => recentActivityIds.value.add(id))
|
||||
|
||||
// Remove from recent set after animation
|
||||
setTimeout(() => {
|
||||
newIds.forEach(id => recentActivityIds.value.delete(id))
|
||||
}, 3000)
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-feed {
|
||||
@apply bg-white rounded-xl border border-neutral-200 shadow-soft overflow-hidden;
|
||||
transition: all 300ms ease-page;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
@apply flex items-center justify-between p-4 border-b border-neutral-100 bg-neutral-50/50;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
@apply text-lg font-semibold text-neutral-900;
|
||||
}
|
||||
|
||||
.activity-count {
|
||||
@apply text-sm text-neutral-500 font-medium;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.view-all-btn {
|
||||
@apply p-2 rounded-lg text-neutral-400 hover:text-primary-600 hover:bg-primary-50 transition-all duration-micro;
|
||||
}
|
||||
|
||||
.feed-content {
|
||||
@apply relative;
|
||||
max-height: v-bind(maxHeight);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.feed-loading {
|
||||
@apply p-8;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
@apply flex flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@apply text-sm text-neutral-500 font-medium;
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.feed-error {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
@apply flex flex-col items-center gap-3 text-center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@apply text-2xl text-error-500;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@apply text-sm text-error-600 font-medium;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
@apply px-4 py-2 bg-error-50 text-error-700 rounded-lg border border-error-200 hover:bg-error-100 transition-colors duration-micro;
|
||||
}
|
||||
|
||||
/* Activity Groups */
|
||||
.activity-groups {
|
||||
@apply divide-y divide-neutral-100;
|
||||
}
|
||||
|
||||
.activity-group {
|
||||
@apply relative;
|
||||
animation: fade-in 300ms ease-out;
|
||||
}
|
||||
|
||||
.activity-group.new-group {
|
||||
animation: scale-in 150ms ease-out;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
@apply flex items-center justify-between px-4 py-3 bg-neutral-50/30;
|
||||
}
|
||||
|
||||
.group-time {
|
||||
@apply text-sm font-semibold text-neutral-700;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
@apply text-xs text-neutral-500 font-medium;
|
||||
}
|
||||
|
||||
.group-activities {
|
||||
@apply divide-y divide-neutral-50;
|
||||
}
|
||||
|
||||
/* Load More */
|
||||
.load-more-sentinel {
|
||||
@apply flex items-center justify-center p-4 transition-opacity duration-micro;
|
||||
}
|
||||
|
||||
.load-more-sentinel:not(.loading) {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
@apply text-sm text-neutral-500;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.feed-empty {
|
||||
@apply p-8;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
@apply flex flex-col items-center text-center gap-4;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
@apply w-16 h-16 rounded-full bg-neutral-100 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply text-2xl text-neutral-400;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@apply text-lg font-semibold text-neutral-700;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
@apply text-sm text-neutral-500 max-w-xs leading-relaxed;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
@apply flex gap-3 mt-2;
|
||||
}
|
||||
|
||||
.empty-action-btn {
|
||||
@apply flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-micro;
|
||||
}
|
||||
|
||||
.empty-action-btn.primary {
|
||||
@apply bg-primary-500 text-white hover:bg-primary-600 shadow-soft;
|
||||
}
|
||||
|
||||
/* Social Proof */
|
||||
.social-proof {
|
||||
@apply border-t border-neutral-100 bg-gradient-to-r from-success-50 to-primary-50 p-4;
|
||||
}
|
||||
|
||||
.social-proof-content {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.social-proof-icon {
|
||||
@apply text-success-500 text-lg;
|
||||
}
|
||||
|
||||
.social-proof-text {
|
||||
@apply text-sm text-success-700 font-medium;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.feed-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.feed-content::-webkit-scrollbar-track {
|
||||
@apply bg-neutral-100;
|
||||
}
|
||||
|
||||
.feed-content::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-300 rounded-full;
|
||||
}
|
||||
|
||||
.feed-content::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-neutral-400;
|
||||
}
|
||||
</style>
|
@ -1,44 +1,630 @@
|
||||
<template>
|
||||
<div class="flex items-start space-x-3 py-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
|
||||
<BaseIcon :name="iconName" class="h-5 w-5 text-gray-500" />
|
||||
<div class="activity-item" :class="activityClasses" @click="handleClick" @mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false">
|
||||
<!-- Activity Icon with Status -->
|
||||
<div class="activity-icon-container">
|
||||
<div class="activity-icon" :class="iconClasses">
|
||||
<span class="material-icons icon">{{ materialIcon }}</span>
|
||||
|
||||
<!-- Celebration Animation for Completions -->
|
||||
<div v-if="isCompletion && showCelebration" class="celebration-particles">
|
||||
<div class="particle" v-for="i in 6" :key="`particle-${i}`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Avatar -->
|
||||
<div class="user-avatar" v-if="activity.user">
|
||||
<div class="avatar-fallback">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" v-html="activity.message"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<time :datetime="activity.timestamp">{{ formattedTimestamp }}</time>
|
||||
</p>
|
||||
|
||||
<!-- Activity Content -->
|
||||
<div class="activity-content">
|
||||
<!-- Message -->
|
||||
<div class="activity-message">
|
||||
<p class="message-text" v-html="enhancedMessage"></p>
|
||||
|
||||
<!-- Action Badges -->
|
||||
<div v-if="actionBadges.length > 0" class="action-badges">
|
||||
<span v-for="badge in actionBadges" :key="badge.text" class="action-badge" :class="badge.variant">
|
||||
{{ badge.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="activity-metadata">
|
||||
<div class="metadata-left">
|
||||
<time class="activity-time" :datetime="activity.timestamp">
|
||||
{{ formattedTimestamp }}
|
||||
</time>
|
||||
|
||||
<!-- Location/Context -->
|
||||
<span v-if="contextInfo" class="context-info">
|
||||
<span class="material-icons context-icon">{{ contextInfo.icon }}</span>
|
||||
{{ contextInfo.text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div v-if="isHovered && quickActions.length > 0" class="quick-actions">
|
||||
<button v-for="action in quickActions" :key="action.key" class="quick-action-btn"
|
||||
:class="action.variant" @click.stop="handleQuickAction(action)" :aria-label="action.label">
|
||||
<span class="material-icons">{{ action.icon }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Preview -->
|
||||
<div v-if="showDetails && activityDetails" class="activity-details">
|
||||
<div class="details-content">
|
||||
<div v-for="(value, key) in activityDetails" :key="String(key)" class="detail-item">
|
||||
<span class="detail-label">{{ formatDetailKey(String(key)) }}:</span>
|
||||
<span class="detail-value">{{ formatDetailValue(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Indicator -->
|
||||
<div v-if="statusIndicator" class="status-indicator" :class="statusIndicator.variant">
|
||||
<span class="material-icons status-icon">{{ statusIndicator.icon }}</span>
|
||||
</div>
|
||||
|
||||
<!-- New Activity Pulse -->
|
||||
<div v-if="isNew" class="new-activity-pulse"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { type Activity, ActivityEventType } from '@/types/activity'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
// Props
|
||||
interface Props {
|
||||
activity: Activity
|
||||
isNew?: boolean
|
||||
showDetails?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isNew: false,
|
||||
showDetails: false,
|
||||
compact: false,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'click': [activity: Activity]
|
||||
'quick-action': [action: string, activity: Activity]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const isHovered = ref(false)
|
||||
const showCelebration = ref(false)
|
||||
|
||||
// Computed Properties
|
||||
const formattedTimestamp = computed(() => {
|
||||
return formatDistanceToNow(new Date(props.activity.timestamp), { addSuffix: true })
|
||||
})
|
||||
|
||||
const iconMap: Record<ActivityEventType, string> = {
|
||||
[ActivityEventType.CHORE_COMPLETED]: 'heroicons:check-circle',
|
||||
[ActivityEventType.CHORE_CREATED]: 'heroicons:plus-circle',
|
||||
[ActivityEventType.EXPENSE_CREATED]: 'heroicons:currency-dollar',
|
||||
[ActivityEventType.EXPENSE_SETTLED]: 'heroicons:receipt-percent',
|
||||
[ActivityEventType.ITEM_ADDED]: 'heroicons:shopping-cart',
|
||||
[ActivityEventType.ITEM_COMPLETED]: 'heroicons:check',
|
||||
[ActivityEventType.USER_JOINED_GROUP]: 'heroicons:user-plus',
|
||||
const userInitials = computed(() => {
|
||||
if (!props.activity.user?.name) return '?'
|
||||
return props.activity.user.name
|
||||
.split(' ')
|
||||
.map(name => name.charAt(0))
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase()
|
||||
})
|
||||
|
||||
const isCompletion = computed(() => {
|
||||
return props.activity.event_type.includes('completed')
|
||||
})
|
||||
|
||||
// Icon mapping with Material Icons
|
||||
const materialIconMap: Record<ActivityEventType, string> = {
|
||||
[ActivityEventType.CHORE_COMPLETED]: 'task_alt',
|
||||
[ActivityEventType.CHORE_CREATED]: 'add_task',
|
||||
[ActivityEventType.EXPENSE_CREATED]: 'payments',
|
||||
[ActivityEventType.EXPENSE_SETTLED]: 'receipt_long',
|
||||
[ActivityEventType.ITEM_ADDED]: 'add_shopping_cart',
|
||||
[ActivityEventType.ITEM_COMPLETED]: 'shopping_cart_checkout',
|
||||
[ActivityEventType.USER_JOINED_GROUP]: 'person_add',
|
||||
}
|
||||
|
||||
const iconName = computed(() => {
|
||||
return iconMap[props.activity.event_type] || 'heroicons:question-mark-circle'
|
||||
const materialIcon = computed(() => {
|
||||
return materialIconMap[props.activity.event_type] || 'info'
|
||||
})
|
||||
</script>
|
||||
|
||||
// Enhanced message with formatting
|
||||
const enhancedMessage = computed(() => {
|
||||
let message = props.activity.message
|
||||
|
||||
// Bold user names
|
||||
const userName = props.activity.user?.name
|
||||
if (userName) {
|
||||
message = message.replace(userName, `<strong>${userName}</strong>`)
|
||||
}
|
||||
|
||||
// Highlight items in quotes
|
||||
message = message.replace(/'([^']+)'/g, '<span class="highlighted-item">$1</span>')
|
||||
|
||||
return message
|
||||
})
|
||||
|
||||
// Activity styling classes
|
||||
const activityClasses = computed(() => [
|
||||
'activity-item-base',
|
||||
{
|
||||
'activity-compact': props.compact,
|
||||
'activity-new': props.isNew,
|
||||
'activity-completion': isCompletion.value,
|
||||
'activity-interactive': true,
|
||||
}
|
||||
])
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
const baseClasses = ['activity-icon-base']
|
||||
|
||||
switch (props.activity.event_type) {
|
||||
case ActivityEventType.CHORE_COMPLETED:
|
||||
baseClasses.push('icon-success')
|
||||
break
|
||||
case ActivityEventType.EXPENSE_CREATED:
|
||||
baseClasses.push('icon-warning')
|
||||
break
|
||||
case ActivityEventType.EXPENSE_SETTLED:
|
||||
baseClasses.push('icon-success')
|
||||
break
|
||||
case ActivityEventType.ITEM_COMPLETED:
|
||||
baseClasses.push('icon-success')
|
||||
break
|
||||
case ActivityEventType.USER_JOINED_GROUP:
|
||||
baseClasses.push('icon-primary')
|
||||
break
|
||||
default:
|
||||
baseClasses.push('icon-neutral')
|
||||
}
|
||||
|
||||
return baseClasses
|
||||
})
|
||||
|
||||
// Action badges for different activity types
|
||||
const actionBadges = computed(() => {
|
||||
const badges: Array<{ text: string; variant: string }> = []
|
||||
|
||||
if (props.activity.event_type === ActivityEventType.CHORE_COMPLETED) {
|
||||
badges.push({ text: 'Completed', variant: 'badge-success' })
|
||||
} else if (props.activity.event_type === ActivityEventType.EXPENSE_SETTLED) {
|
||||
badges.push({ text: 'Settled', variant: 'badge-success' })
|
||||
} else if (props.activity.event_type === ActivityEventType.EXPENSE_CREATED) {
|
||||
badges.push({ text: 'New Expense', variant: 'badge-warning' })
|
||||
}
|
||||
|
||||
return badges
|
||||
})
|
||||
|
||||
// Context information based on activity type
|
||||
const contextInfo = computed(() => {
|
||||
const details = props.activity.details
|
||||
|
||||
if (details?.list_id) {
|
||||
return { icon: 'list', text: 'Shopping List' }
|
||||
} else if (details?.chore_name) {
|
||||
return { icon: 'home', text: 'Household' }
|
||||
} else if (props.activity.event_type.includes('expense')) {
|
||||
return { icon: 'account_balance_wallet', text: 'Expenses' }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// Quick actions based on activity type
|
||||
const quickActions = computed(() => {
|
||||
const actions: Array<{ key: string; icon: string; label: string; variant: string }> = []
|
||||
|
||||
if (props.activity.event_type.includes('chore')) {
|
||||
actions.push({
|
||||
key: 'view-chores',
|
||||
icon: 'task_alt',
|
||||
label: 'View chores',
|
||||
variant: 'action-primary'
|
||||
})
|
||||
} else if (props.activity.event_type.includes('expense')) {
|
||||
actions.push({
|
||||
key: 'view-expenses',
|
||||
icon: 'payments',
|
||||
label: 'View expenses',
|
||||
variant: 'action-warning'
|
||||
})
|
||||
} else if (props.activity.event_type.includes('item')) {
|
||||
actions.push({
|
||||
key: 'view-lists',
|
||||
icon: 'list',
|
||||
label: 'View lists',
|
||||
variant: 'action-neutral'
|
||||
})
|
||||
}
|
||||
|
||||
// Always add a "details" action
|
||||
actions.push({
|
||||
key: 'view-details',
|
||||
icon: 'info',
|
||||
label: 'View details',
|
||||
variant: 'action-neutral'
|
||||
})
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
// Status indicator for certain activities
|
||||
const statusIndicator = computed(() => {
|
||||
if (props.isNew) {
|
||||
return { icon: 'fiber_new', variant: 'status-new' }
|
||||
}
|
||||
|
||||
if (isCompletion.value) {
|
||||
return { icon: 'check_circle', variant: 'status-success' }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// Activity details for preview
|
||||
const activityDetails = computed(() => {
|
||||
if (!props.showDetails) return null
|
||||
|
||||
const filteredDetails = { ...props.activity.details }
|
||||
delete filteredDetails.user_id // Remove internal IDs
|
||||
delete filteredDetails.id
|
||||
|
||||
return Object.keys(filteredDetails).length > 0 ? filteredDetails : null
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const formatDetailKey = (key: string): string => {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
const formatDetailValue = (value: any): string => {
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleClick = () => {
|
||||
emit('click', props.activity)
|
||||
}
|
||||
|
||||
const handleQuickAction = (action: { key: string }) => {
|
||||
emit('quick-action', action.key, props.activity)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (isCompletion.value && props.isNew) {
|
||||
// Show celebration animation for new completions
|
||||
setTimeout(() => {
|
||||
showCelebration.value = true
|
||||
setTimeout(() => {
|
||||
showCelebration.value = false
|
||||
}, 2000)
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-item {
|
||||
@apply relative flex items-start gap-3 p-4 transition-all duration-micro cursor-pointer;
|
||||
}
|
||||
|
||||
.activity-item-base {
|
||||
@apply hover:bg-neutral-50 rounded-lg;
|
||||
}
|
||||
|
||||
.activity-item.activity-compact {
|
||||
@apply p-3;
|
||||
}
|
||||
|
||||
.activity-item.activity-new {
|
||||
@apply bg-primary-50/50 border-l-2 border-primary-400;
|
||||
animation: slide-in-left 300ms ease-out;
|
||||
}
|
||||
|
||||
.activity-item.activity-completion {
|
||||
@apply hover:bg-success-50/50;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
@apply shadow-soft;
|
||||
}
|
||||
|
||||
/* Icon Container */
|
||||
.activity-icon-container {
|
||||
@apply relative flex-shrink-0;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
@apply w-10 h-10 rounded-full flex items-center justify-center relative overflow-hidden;
|
||||
transition: all 150ms ease-micro;
|
||||
}
|
||||
|
||||
.activity-icon-base {
|
||||
@apply border-2;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
@apply bg-success-100 border-success-300 text-success-700;
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
@apply bg-warning-100 border-warning-300 text-warning-700;
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
@apply bg-primary-100 border-primary-300 text-primary-700;
|
||||
}
|
||||
|
||||
.icon-neutral {
|
||||
@apply bg-neutral-100 border-neutral-300 text-neutral-600;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-lg relative z-10;
|
||||
}
|
||||
|
||||
/* User Avatar */
|
||||
.user-avatar {
|
||||
@apply absolute -bottom-1 -right-1 w-6 h-6 rounded-full border-2 border-white overflow-hidden;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
@apply w-full h-full bg-neutral-300 text-white text-xs font-semibold flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* Activity Content */
|
||||
.activity-content {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.activity-message {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
@apply text-sm text-neutral-800 leading-relaxed;
|
||||
}
|
||||
|
||||
.message-text :deep(strong) {
|
||||
@apply font-semibold text-neutral-900;
|
||||
}
|
||||
|
||||
.message-text :deep(.highlighted-item) {
|
||||
@apply font-medium text-primary-700 bg-primary-50 px-1 rounded;
|
||||
}
|
||||
|
||||
/* Action Badges */
|
||||
.action-badges {
|
||||
@apply flex flex-wrap gap-2 mt-2;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
@apply inline-flex items-center px-2 py-1 text-xs font-medium rounded-full;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary-100 text-primary-800;
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.activity-metadata {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.metadata-left {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
@apply text-xs text-neutral-500 font-medium;
|
||||
}
|
||||
|
||||
.context-info {
|
||||
@apply flex items-center gap-1 text-xs text-neutral-500;
|
||||
}
|
||||
|
||||
.context-icon {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
@apply flex items-center gap-1;
|
||||
animation: fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
@apply p-1.5 rounded-lg transition-all duration-micro;
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@apply text-primary-600 hover:bg-primary-100;
|
||||
}
|
||||
|
||||
.action-warning {
|
||||
@apply text-warning-600 hover:bg-warning-100;
|
||||
}
|
||||
|
||||
.action-neutral {
|
||||
@apply text-neutral-500 hover:bg-neutral-100;
|
||||
}
|
||||
|
||||
.quick-action-btn .material-icons {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
/* Activity Details */
|
||||
.activity-details {
|
||||
@apply mt-3 p-3 bg-neutral-50 rounded-lg border border-neutral-200;
|
||||
}
|
||||
|
||||
.details-content {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
@apply flex gap-2 text-xs;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
@apply font-medium text-neutral-600;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
@apply text-neutral-800;
|
||||
}
|
||||
|
||||
/* Status Indicator */
|
||||
.status-indicator {
|
||||
@apply absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.status-new {
|
||||
@apply bg-primary-500 text-white;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
@apply bg-success-500 text-white;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
/* New Activity Pulse */
|
||||
.new-activity-pulse {
|
||||
@apply absolute -top-1 -left-1 w-3 h-3 bg-primary-500 rounded-full;
|
||||
animation: pulse-scale 2s infinite;
|
||||
}
|
||||
|
||||
/* Celebration Animation */
|
||||
.celebration-particles {
|
||||
@apply absolute inset-0 pointer-events-none;
|
||||
}
|
||||
|
||||
.particle {
|
||||
@apply absolute w-1 h-1 bg-success-400 rounded-full;
|
||||
animation: celebration-burst 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.particle:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.particle:nth-child(2) {
|
||||
animation-delay: 100ms;
|
||||
transform: rotate(60deg);
|
||||
}
|
||||
|
||||
.particle:nth-child(3) {
|
||||
animation-delay: 200ms;
|
||||
transform: rotate(120deg);
|
||||
}
|
||||
|
||||
.particle:nth-child(4) {
|
||||
animation-delay: 300ms;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.particle:nth-child(5) {
|
||||
animation-delay: 400ms;
|
||||
transform: rotate(240deg);
|
||||
}
|
||||
|
||||
.particle:nth-child(6) {
|
||||
animation-delay: 500ms;
|
||||
transform: rotate(300deg);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slide-in-left {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-scale {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes celebration-burst {
|
||||
0% {
|
||||
transform: translateX(0) translateY(0) scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(20px) translateY(-20px) scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.activity-item:hover .activity-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.activity-item.activity-completion:hover .activity-icon.icon-success {
|
||||
@apply shadow-success-300;
|
||||
box-shadow: 0 0 0 4px rgb(16 185 129 / 20%);
|
||||
}
|
||||
</style>
|
@ -1,54 +1,284 @@
|
||||
<template>
|
||||
<div class="rounded-lg bg-white dark:bg-dark shadow p-4">
|
||||
<div v-if="isLoading" class="text-center text-gray-500">
|
||||
Loading your status...
|
||||
</div>
|
||||
<div v-else class="flex items-center space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="h-12 w-12 rounded-full flex items-center justify-center" :class="iconBgColor">
|
||||
<BaseIcon :name="iconName" class="h-6 w-6 text-white" />
|
||||
<Card class="personal-status-card">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Heading size="lg" class="text-neutral-900">
|
||||
{{ greeting }}
|
||||
</Heading>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="personalStatus.streakCount > 0" class="streak-indicator">
|
||||
<span class="material-icons text-warning-500 text-sm">local_fire_department</span>
|
||||
<span class="text-xs font-medium text-warning-600">{{ personalStatus.streakCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ nextAction.title }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ nextAction.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Status Content -->
|
||||
<div class="status-content">
|
||||
<!-- Priority Action -->
|
||||
<div v-if="priorityAction" class="priority-action-section mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="priority-icon">
|
||||
<span class="material-icons" :class="getPriorityIconClass(priorityAction.priority)">
|
||||
{{ getPriorityIcon(priorityAction.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-medium text-neutral-900 mb-1">
|
||||
{{ getPriorityActionTitle(priorityAction) }}
|
||||
</h3>
|
||||
<p class="text-xs text-neutral-600 mb-3">
|
||||
{{ getPriorityActionDescription(priorityAction) }}
|
||||
</p>
|
||||
<Button :variant="getPriorityActionVariant(priorityAction.priority)"
|
||||
:color="getPriorityActionColor(priorityAction.priority)" size="sm"
|
||||
@click="handlePriorityAction(priorityAction)" class="w-full">
|
||||
{{ getPriorityActionButtonText(priorityAction) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<router-link :to="nextAction.path">
|
||||
<Button variant="solid">{{ nextAction.cta }}</Button>
|
||||
</router-link>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state text-center py-8">
|
||||
<div class="mb-4">
|
||||
<span class="material-icons text-success-400 text-4xl">check_circle</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-neutral-900 mb-2">
|
||||
{{ getEmptyStateTitle() }}
|
||||
</h3>
|
||||
<p class="text-xs text-neutral-600 mb-4">
|
||||
{{ getEmptyStateMessage() }}
|
||||
</p>
|
||||
<Button variant="soft" color="success" size="sm" @click="handleCreateSomething">
|
||||
{{ getEmptyStateAction() }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div v-if="priorityAction" class="quick-stats grid grid-cols-3 gap-4 mt-6 pt-6 border-t border-neutral-200">
|
||||
<div class="stat-item text-center">
|
||||
<div class="text-lg font-semibold text-neutral-900">{{ personalStatus.completedToday }}</div>
|
||||
<div class="text-xs text-neutral-500">Completed</div>
|
||||
</div>
|
||||
<div class="stat-item text-center">
|
||||
<div class="text-lg font-semibold" :class="getBalanceColor(personalStatus.netBalance)">
|
||||
{{ formatCurrency(personalStatus.netBalance) }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500">Balance</div>
|
||||
</div>
|
||||
<div class="stat-item text-center">
|
||||
<div class="text-lg font-semibold text-neutral-900">{{ personalStatus.pointsThisWeek }}</div>
|
||||
<div class="text-xs text-neutral-500">Points</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { usePersonalStatus } from '@/composables/usePersonalStatus';
|
||||
import BaseIcon from '@/components/BaseIcon.vue';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import { computed } from 'vue'
|
||||
import { usePersonalStatus } from '@/composables/usePersonalStatus'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Heading from '@/components/ui/Heading.vue'
|
||||
|
||||
const { nextAction, isLoading } = usePersonalStatus();
|
||||
interface PriorityAction {
|
||||
id: string
|
||||
type: 'chore' | 'expense' | 'debt' | 'overdue'
|
||||
priority: 'urgent' | 'high' | 'medium' | 'low'
|
||||
title: string
|
||||
description?: string
|
||||
dueDate?: Date
|
||||
amount?: number
|
||||
actionUrl: string
|
||||
}
|
||||
|
||||
const iconName = computed(() => {
|
||||
switch (nextAction.value.type) {
|
||||
const { personalStatus, priorityAction } = usePersonalStatus()
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Good morning!'
|
||||
if (hour < 17) return 'Good afternoon!'
|
||||
return 'Good evening!'
|
||||
})
|
||||
|
||||
const getPriorityIcon = (type: string) => {
|
||||
const icons = {
|
||||
chore: 'task_alt',
|
||||
expense: 'receipt',
|
||||
debt: 'payments',
|
||||
overdue: 'schedule'
|
||||
}
|
||||
return icons[type as keyof typeof icons] || 'info'
|
||||
}
|
||||
|
||||
const getPriorityIconClass = (priority: string) => {
|
||||
const classes = {
|
||||
urgent: 'text-error-500 bg-error-100 p-2 rounded-full',
|
||||
high: 'text-warning-500 bg-warning-100 p-2 rounded-full',
|
||||
medium: 'text-primary-500 bg-primary-100 p-2 rounded-full',
|
||||
low: 'text-neutral-500 bg-neutral-100 p-2 rounded-full'
|
||||
}
|
||||
return classes[priority as keyof typeof classes] || classes.low
|
||||
}
|
||||
|
||||
const getPriorityActionTitle = (action: PriorityAction) => {
|
||||
switch (action.type) {
|
||||
case 'chore':
|
||||
return 'heroicons:bell-alert';
|
||||
return `Complete: ${action.title}`
|
||||
case 'expense':
|
||||
return 'heroicons:credit-card';
|
||||
return `Add expense: ${action.title}`
|
||||
case 'debt':
|
||||
return `Settle up: ${action.title}`
|
||||
case 'overdue':
|
||||
return `Overdue: ${action.title}`
|
||||
default:
|
||||
return 'heroicons:check-circle';
|
||||
return action.title
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const iconBgColor = computed(() => {
|
||||
switch (nextAction.value.priority) {
|
||||
case 1:
|
||||
return 'bg-red-500';
|
||||
case 2:
|
||||
return 'bg-yellow-500';
|
||||
const getPriorityActionDescription = (action: PriorityAction) => {
|
||||
if (action.description) return action.description
|
||||
|
||||
switch (action.type) {
|
||||
case 'chore':
|
||||
return action.dueDate ? `Due ${formatRelativeTime(action.dueDate)}` : 'Ready to complete'
|
||||
case 'expense':
|
||||
return 'Quick expense entry'
|
||||
case 'debt':
|
||||
return action.amount ? `Amount: ${formatCurrency(action.amount)}` : 'Resolve outstanding balance'
|
||||
case 'overdue':
|
||||
return `This was due ${formatRelativeTime(action.dueDate!)}`
|
||||
default:
|
||||
return 'bg-green-500';
|
||||
return ''
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
const getPriorityActionVariant = (priority: string) => {
|
||||
return priority === 'urgent' ? 'solid' : 'soft'
|
||||
}
|
||||
|
||||
const getPriorityActionColor = (priority: string): 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral' => {
|
||||
const colors: Record<string, 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral'> = {
|
||||
urgent: 'error',
|
||||
high: 'warning',
|
||||
medium: 'primary',
|
||||
low: 'neutral'
|
||||
}
|
||||
return colors[priority] || 'primary'
|
||||
}
|
||||
|
||||
const getPriorityActionButtonText = (action: PriorityAction) => {
|
||||
switch (action.type) {
|
||||
case 'chore':
|
||||
return 'Mark Complete'
|
||||
case 'expense':
|
||||
return 'Add Expense'
|
||||
case 'debt':
|
||||
return 'Settle Up'
|
||||
case 'overdue':
|
||||
return 'View Details'
|
||||
default:
|
||||
return 'Take Action'
|
||||
}
|
||||
}
|
||||
|
||||
const getEmptyStateTitle = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'You\'re all caught up!'
|
||||
if (hour < 17) return 'Looking good!'
|
||||
return 'All done for today!'
|
||||
}
|
||||
|
||||
const getEmptyStateMessage = () => {
|
||||
const messages = [
|
||||
'No urgent tasks right now. Great job staying on top of things!',
|
||||
'Everything is up to date. Maybe add something to your list?',
|
||||
'Your household is running smoothly. Time to relax!',
|
||||
'All tasks complete! Consider planning something fun.'
|
||||
]
|
||||
return messages[Math.floor(Math.random() * messages.length)]
|
||||
}
|
||||
|
||||
const getEmptyStateAction = () => {
|
||||
const actions = [
|
||||
'Add New Task',
|
||||
'Create Shopping List',
|
||||
'Plan Chore',
|
||||
'Quick Add'
|
||||
]
|
||||
return actions[Math.floor(Math.random() * actions.length)]
|
||||
}
|
||||
|
||||
const getBalanceColor = (balance: number) => {
|
||||
if (balance > 0) return 'text-success-600'
|
||||
if (balance < 0) return 'text-error-600'
|
||||
return 'text-neutral-600'
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatRelativeTime = (date: Date) => {
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return 'today'
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days > 0) return `${days} days ago`
|
||||
if (days === -1) return 'tomorrow'
|
||||
return `in ${Math.abs(days)} days`
|
||||
}
|
||||
|
||||
const handlePriorityAction = (action: PriorityAction) => {
|
||||
// This will be handled by the parent component or router
|
||||
// For now, we emit an event
|
||||
emit('priority-action', action)
|
||||
}
|
||||
|
||||
const handleCreateSomething = () => {
|
||||
emit('create-action')
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'priority-action': [action: PriorityAction]
|
||||
'create-action': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.personal-status-card {
|
||||
@apply bg-white border border-neutral-200 shadow-soft;
|
||||
animation: fade-in 300ms ease-out;
|
||||
}
|
||||
|
||||
.priority-action-section {
|
||||
@apply p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100;
|
||||
}
|
||||
|
||||
.priority-icon {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply px-4;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
transition: transform 150ms ease-micro;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.streak-indicator {
|
||||
@apply flex items-center gap-1 px-2 py-1 bg-warning-50 rounded-full border border-warning-200;
|
||||
}
|
||||
</style>
|
513
fe/src/components/dashboard/QuickWinSuggestion.vue
Normal file
513
fe/src/components/dashboard/QuickWinSuggestion.vue
Normal file
@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<Card variant="soft" color="primary" padding="lg" class="quick-win-suggestion">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="suggestion-loading">
|
||||
<Spinner size="sm" />
|
||||
<span class="loading-text">Finding your next quick win...</span>
|
||||
</div>
|
||||
|
||||
<!-- Suggestion Content -->
|
||||
<div v-else-if="currentSuggestion" class="suggestion-content">
|
||||
<!-- Suggestion Header -->
|
||||
<div class="suggestion-header">
|
||||
<div class="suggestion-icon">
|
||||
<BaseIcon :name="getSuggestionIconName(currentSuggestion.icon)" :class="iconVariantClasses" />
|
||||
</div>
|
||||
<div class="suggestion-meta">
|
||||
<span class="suggestion-type">{{ currentSuggestion.type }}</span>
|
||||
<span class="suggestion-points">+{{ currentSuggestion.points }} points</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggestion Body -->
|
||||
<div class="suggestion-body">
|
||||
<h3 class="suggestion-title">{{ currentSuggestion.title }}</h3>
|
||||
<p class="suggestion-description">{{ currentSuggestion.description }}</p>
|
||||
|
||||
<!-- Context Information -->
|
||||
<div v-if="currentSuggestion.context" class="suggestion-context">
|
||||
<span class="context-indicator">
|
||||
<BaseIcon name="heroicons:information-circle-20-solid" class="context-icon" />
|
||||
{{ currentSuggestion.context }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggestion Actions -->
|
||||
<div class="suggestion-actions">
|
||||
<Button :variant="currentSuggestion.priority === 'high' ? 'solid' : 'outline'" :color="actionColor"
|
||||
size="sm" fullWidth @click="handleTakeAction" :loading="isActioning" class="primary-action">
|
||||
<template #icon-left>
|
||||
<BaseIcon :name="getSuggestionIconName(currentSuggestion.actionIcon)" />
|
||||
</template>
|
||||
{{ currentSuggestion.actionText }}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" color="neutral" size="sm" @click="handleDismiss" class="dismiss-btn">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:x-mark-20-solid" />
|
||||
</template>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="suggestion-empty">
|
||||
<div class="empty-icon">
|
||||
<BaseIcon name="heroicons:trophy-20-solid" class="trophy-icon" />
|
||||
</div>
|
||||
<h3 class="empty-title">All caught up!</h3>
|
||||
<p class="empty-description">No immediate suggestions right now. Check back later for new opportunities.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChoreStore } from '@/stores/choreStore'
|
||||
import { useListsStore } from '@/stores/listsStore'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { Card, Button, Spinner } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
interface Suggestion {
|
||||
id: string
|
||||
type: 'Chore' | 'Shopping' | 'Expense' | 'Organization'
|
||||
priority: 'high' | 'medium' | 'low'
|
||||
title: string
|
||||
description: string
|
||||
context?: string
|
||||
icon: string
|
||||
actionIcon: string
|
||||
actionText: string
|
||||
points: number
|
||||
action: () => void | Promise<void>
|
||||
target?: any // The target item/chore/etc for the action
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const choreStore = useChoreStore()
|
||||
const listsStore = useListsStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isActioning = ref(false)
|
||||
const currentSuggestion = ref<Suggestion | null>(null)
|
||||
const suggestionHistory = ref<Set<string>>(new Set())
|
||||
const refreshInterval = ref<number | null>(null)
|
||||
|
||||
// Enhanced icon mapping for better visual consistency
|
||||
const getSuggestionIconName = (materialIcon: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'warning': 'heroicons:exclamation-triangle-20-solid',
|
||||
'today': 'heroicons:calendar-20-solid',
|
||||
'star': 'heroicons:star-20-solid',
|
||||
'shopping_cart': 'heroicons:shopping-cart-20-solid',
|
||||
'schedule': 'heroicons:clock-20-solid',
|
||||
'check_circle': 'heroicons:check-circle-20-solid',
|
||||
'play_arrow': 'heroicons:play-20-solid',
|
||||
'volunteer_activism': 'heroicons:hand-raised-20-solid',
|
||||
'store': 'heroicons:building-storefront-20-solid',
|
||||
'calendar_view_day': 'heroicons:calendar-days-20-solid',
|
||||
'emoji_events': 'heroicons:trophy-20-solid',
|
||||
'lightbulb': 'heroicons:light-bulb-20-solid',
|
||||
'target': 'heroicons:cursor-arrow-rays-20-solid',
|
||||
'flash_on': 'heroicons:bolt-20-solid',
|
||||
'trending_up': 'heroicons:arrow-trending-up-20-solid'
|
||||
}
|
||||
return iconMap[materialIcon] || 'heroicons:sparkles-20-solid'
|
||||
}
|
||||
|
||||
// Enhanced color scheme based on priority and type
|
||||
const iconVariantClasses = computed(() => {
|
||||
if (!currentSuggestion.value) return ''
|
||||
const { priority, type } = currentSuggestion.value
|
||||
|
||||
if (priority === 'high') return 'text-error-600 dark:text-error-400'
|
||||
if (type === 'Chore') return 'text-primary-600 dark:text-primary-400'
|
||||
if (type === 'Shopping') return 'text-success-600 dark:text-success-400'
|
||||
if (type === 'Expense') return 'text-warning-600 dark:text-warning-400'
|
||||
return 'text-text-secondary'
|
||||
})
|
||||
|
||||
const actionColor = computed(() => {
|
||||
if (!currentSuggestion.value) return 'primary'
|
||||
const { priority } = currentSuggestion.value
|
||||
|
||||
if (priority === 'high') return 'error'
|
||||
return 'primary'
|
||||
})
|
||||
|
||||
// Generate intelligent suggestions based on user data and patterns
|
||||
const generateSuggestions = (): Suggestion[] => {
|
||||
const suggestions: Suggestion[] = []
|
||||
const now = new Date()
|
||||
const user = authStore.user
|
||||
|
||||
if (!user) return suggestions
|
||||
|
||||
// 1. OVERDUE CHORES (Highest Priority)
|
||||
const allChores = choreStore.allChores
|
||||
const overdueChores = allChores.filter((chore: any) => {
|
||||
if (!chore.assignments || chore.assignments.length === 0) return false
|
||||
const latestAssignment = chore.assignments[chore.assignments.length - 1]
|
||||
if (latestAssignment.is_complete) return false
|
||||
const dueDate = new Date(latestAssignment.due_date)
|
||||
return dueDate < now
|
||||
})
|
||||
|
||||
if (overdueChores.length > 0) {
|
||||
const urgentChore = overdueChores[0]
|
||||
const assignment = urgentChore.assignments[urgentChore.assignments.length - 1]
|
||||
const daysOverdue = Math.ceil((now.getTime() - new Date(assignment.due_date).getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
suggestions.push({
|
||||
id: `overdue-chore-${urgentChore.id}`,
|
||||
type: 'Chore',
|
||||
priority: 'high',
|
||||
title: `Complete "${urgentChore.name}"`,
|
||||
description: 'This task is overdue and may be blocking others.',
|
||||
context: `Overdue by ${daysOverdue} days`,
|
||||
icon: 'warning',
|
||||
actionIcon: 'check_circle',
|
||||
actionText: 'Complete Now',
|
||||
points: 20, // High priority bonus
|
||||
action: () => handleCompleteChore(urgentChore, assignment),
|
||||
target: urgentChore
|
||||
})
|
||||
}
|
||||
|
||||
// 2. DUE TODAY CHORES
|
||||
const dueTodayChores = allChores.filter((chore: any) => {
|
||||
if (!chore.assignments || chore.assignments.length === 0) return false
|
||||
const latestAssignment = chore.assignments[chore.assignments.length - 1]
|
||||
if (latestAssignment.is_complete) return false
|
||||
const dueDate = new Date(latestAssignment.due_date)
|
||||
return dueDate.toDateString() === now.toDateString()
|
||||
})
|
||||
|
||||
if (dueTodayChores.length > 0) {
|
||||
const todayChore = dueTodayChores[0]
|
||||
const assignment = todayChore.assignments[todayChore.assignments.length - 1]
|
||||
|
||||
suggestions.push({
|
||||
id: `due-today-${todayChore.id}`,
|
||||
type: 'Chore',
|
||||
priority: 'medium',
|
||||
title: `Tackle "${todayChore.name}"`,
|
||||
description: 'Due today - perfect time to get it done!',
|
||||
icon: 'today',
|
||||
actionIcon: 'play_arrow',
|
||||
actionText: 'Start Task',
|
||||
points: 10,
|
||||
action: () => handleCompleteChore(todayChore, assignment),
|
||||
target: todayChore
|
||||
})
|
||||
}
|
||||
|
||||
// 3. AVAILABLE HIGH-POINT CHORES
|
||||
const availableChores = allChores.filter((chore: any) => {
|
||||
if (!chore.assignments || chore.assignments.length === 0) return true // Can be claimed
|
||||
const latestAssignment = chore.assignments[chore.assignments.length - 1]
|
||||
return !latestAssignment.is_complete
|
||||
})
|
||||
|
||||
if (availableChores.length > 0) {
|
||||
const highValueChore = availableChores[0]
|
||||
|
||||
suggestions.push({
|
||||
id: `high-value-${highValueChore.id}`,
|
||||
type: 'Chore',
|
||||
priority: 'medium',
|
||||
title: `Earn big with "${highValueChore.name}"`,
|
||||
description: 'High-value task available for quick points!',
|
||||
icon: 'star',
|
||||
actionIcon: 'target',
|
||||
actionText: 'Claim Task',
|
||||
points: 15,
|
||||
action: () => { router.push('/chores') },
|
||||
target: highValueChore
|
||||
})
|
||||
}
|
||||
|
||||
// 4. SHOPPING OPTIMIZATION (simplified for current list)
|
||||
const currentList = listsStore.currentList
|
||||
if (currentList && currentList.items) {
|
||||
const pendingItems = currentList.items.filter((item: any) => !item.is_complete && !item.claimed_by_user_id)
|
||||
|
||||
if (pendingItems.length >= 3) {
|
||||
suggestions.push({
|
||||
id: `shopping-${currentList.id}`,
|
||||
type: 'Shopping',
|
||||
priority: 'medium',
|
||||
title: 'Complete shopping trip',
|
||||
description: `${pendingItems.length} items waiting on "${currentList.name}"`,
|
||||
icon: 'shopping_cart',
|
||||
actionIcon: 'check_circle',
|
||||
actionText: 'Shop Now',
|
||||
points: Math.min(pendingItems.length * 2, 20),
|
||||
action: () => { router.push(`/lists/${currentList.id}`) },
|
||||
target: currentList
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. QUICK WINS BASED ON TIME
|
||||
const hour = now.getHours()
|
||||
if (hour >= 7 && hour <= 10) {
|
||||
// Morning suggestions
|
||||
suggestions.push({
|
||||
id: 'morning-routine',
|
||||
type: 'Organization',
|
||||
priority: 'low',
|
||||
title: 'Start strong today',
|
||||
description: 'Morning energy is perfect for quick organization tasks',
|
||||
icon: 'lightbulb',
|
||||
actionIcon: 'flash_on',
|
||||
actionText: 'Get Organized',
|
||||
points: 5,
|
||||
action: () => { router.push('/chores?filter=quick') }
|
||||
})
|
||||
} else if (hour >= 18 && hour <= 21) {
|
||||
// Evening wind-down
|
||||
suggestions.push({
|
||||
id: 'evening-prep',
|
||||
type: 'Organization',
|
||||
priority: 'low',
|
||||
title: 'Prep for tomorrow',
|
||||
description: 'Evening planning makes mornings smoother',
|
||||
icon: 'schedule',
|
||||
actionIcon: 'trending_up',
|
||||
actionText: 'Plan Ahead',
|
||||
points: 5,
|
||||
action: () => { router.push('/dashboard') }
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// Actions
|
||||
const handleCompleteChore = async (chore: any, assignment: any) => {
|
||||
try {
|
||||
isActioning.value = true
|
||||
await choreStore.toggleCompletion(chore, assignment)
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `Great job finishing "${chore.name}"`
|
||||
})
|
||||
refreshSuggestions()
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to complete task'
|
||||
})
|
||||
} finally {
|
||||
isActioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTakeAction = async () => {
|
||||
if (!currentSuggestion.value) return
|
||||
|
||||
try {
|
||||
await currentSuggestion.value.action()
|
||||
suggestionHistory.value.add(currentSuggestion.value.id)
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (!currentSuggestion.value) return
|
||||
|
||||
suggestionHistory.value.add(currentSuggestion.value.id)
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
const refreshSuggestions = () => {
|
||||
const allSuggestions = generateSuggestions()
|
||||
const availableSuggestions = allSuggestions.filter(s =>
|
||||
!suggestionHistory.value.has(s.id)
|
||||
)
|
||||
|
||||
// Prioritize by urgency then points
|
||||
availableSuggestions.sort((a, b) => {
|
||||
const priorityWeight = { high: 3, medium: 2, low: 1 }
|
||||
const aPriority = priorityWeight[a.priority]
|
||||
const bPriority = priorityWeight[b.priority]
|
||||
|
||||
if (aPriority !== bPriority) return bPriority - aPriority
|
||||
return b.points - a.points
|
||||
})
|
||||
|
||||
currentSuggestion.value = availableSuggestions[0] || null
|
||||
}
|
||||
|
||||
const setupRefreshInterval = () => {
|
||||
// Refresh suggestions every 5 minutes
|
||||
refreshInterval.value = window.setInterval(refreshSuggestions, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
|
||||
// Load initial data
|
||||
await choreStore.fetchPersonal()
|
||||
|
||||
refreshSuggestions()
|
||||
setupRefreshInterval()
|
||||
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for store updates to refresh suggestions
|
||||
watch([
|
||||
() => choreStore.allChores,
|
||||
() => listsStore.currentList
|
||||
], refreshSuggestions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.quick-win-suggestion {
|
||||
@apply transition-all duration-micro ease-micro;
|
||||
@apply hover:shadow-medium hover:scale-[1.01];
|
||||
@apply transform-gpu;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.suggestion-loading {
|
||||
@apply flex items-center gap-3;
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
/* Suggestion Content */
|
||||
.suggestion-content {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.suggestion-header {
|
||||
@apply flex items-start justify-between;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
@apply flex-shrink-0 w-10 h-10;
|
||||
@apply flex items-center justify-center;
|
||||
@apply rounded-lg bg-surface-elevated;
|
||||
@apply transition-all duration-micro ease-micro;
|
||||
}
|
||||
|
||||
.suggestion-icon svg {
|
||||
@apply w-5 h-5;
|
||||
}
|
||||
|
||||
.suggestion-meta {
|
||||
@apply flex flex-col gap-1 text-right;
|
||||
}
|
||||
|
||||
.suggestion-type {
|
||||
@apply text-xs font-medium text-text-secondary;
|
||||
@apply uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.suggestion-points {
|
||||
@apply text-sm font-semibold text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
/* Suggestion Body */
|
||||
.suggestion-body {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
@apply text-lg font-semibold text-text-primary;
|
||||
@apply leading-tight;
|
||||
}
|
||||
|
||||
.suggestion-description {
|
||||
@apply text-sm text-text-secondary;
|
||||
@apply leading-relaxed;
|
||||
}
|
||||
|
||||
.suggestion-context {
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.context-indicator {
|
||||
@apply inline-flex items-center gap-2;
|
||||
@apply px-3 py-1.5;
|
||||
@apply bg-warning-50 dark:bg-warning-950/50;
|
||||
@apply border border-warning-200 dark:border-warning-800;
|
||||
@apply rounded-md;
|
||||
@apply text-xs font-medium text-warning-700 dark:text-warning-300;
|
||||
}
|
||||
|
||||
.context-icon {
|
||||
@apply w-3.5 h-3.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
/* Suggestion Actions */
|
||||
.suggestion-actions {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.suggestion-empty {
|
||||
@apply text-center py-4;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply flex justify-center mb-3;
|
||||
}
|
||||
|
||||
.trophy-icon {
|
||||
@apply w-8 h-8 text-success-500;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@apply text-lg font-semibold text-text-primary mb-2;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
@apply text-sm text-text-secondary;
|
||||
@apply max-w-xs mx-auto;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.suggestion-header {
|
||||
@apply flex-col gap-3;
|
||||
}
|
||||
|
||||
.suggestion-meta {
|
||||
@apply flex-row justify-between text-left;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
@apply flex-col;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,61 +1,428 @@
|
||||
<template>
|
||||
<div class="fixed bottom-4 right-4">
|
||||
<Menu as="div" class="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton as="template">
|
||||
<Button variant="solid" class="rounded-full w-14 h-14 flex items-center justify-center shadow-lg">
|
||||
<BaseIcon name="heroicons:plus" class="h-7 w-7 text-white" />
|
||||
</Button>
|
||||
</MenuButton>
|
||||
</div>
|
||||
<div class="universal-fab-container">
|
||||
<!-- Main FAB Button -->
|
||||
<Transition name="fab-main" appear>
|
||||
<button ref="fabButton" class="fab-main" :class="{ 'is-open': isOpen }" @click="toggleFAB"
|
||||
@touchstart="handleTouchStart" :aria-expanded="isOpen" aria-label="Quick actions menu">
|
||||
<Transition name="fab-icon" mode="out-in">
|
||||
<span v-if="isOpen" key="close" class="material-icons">close</span>
|
||||
<span v-else key="add" class="material-icons">add</span>
|
||||
</Transition>
|
||||
</button>
|
||||
</Transition>
|
||||
|
||||
<transition enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in" leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0">
|
||||
<MenuItems
|
||||
class="absolute bottom-16 right-0 w-56 origin-bottom-right rounded-md bg-white dark:bg-dark shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div class="px-1 py-1">
|
||||
<MenuItem v-for="item in menuItems" :key="item.label" v-slot="{ active }">
|
||||
<button @click="item.action" :class="[
|
||||
active ? 'bg-primary-500 text-white' : 'text-gray-900 dark:text-gray-100',
|
||||
'group flex w-full items-center rounded-md px-2 py-2 text-sm',
|
||||
]">
|
||||
<BaseIcon :name="item.icon"
|
||||
:class="[active ? 'text-white' : 'text-primary-500', 'mr-2 h-5 w-5']" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
<!-- Action Buttons -->
|
||||
<Transition name="fab-actions">
|
||||
<div v-if="isOpen" class="fab-actions-container">
|
||||
<button v-for="(action, index) in sortedActions" :key="action.id" class="fab-action"
|
||||
:style="getActionPosition(index)" @click="handleActionClick(action)" :aria-label="action.label">
|
||||
<span class="material-icons">{{ action.icon }}</span>
|
||||
<span class="fab-action-label">{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<Transition name="fab-backdrop">
|
||||
<div v-if="isOpen" class="fab-backdrop" @click="closeFAB" @touchstart="closeFAB" />
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const router = useRouter();
|
||||
interface FABAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
action: () => void
|
||||
usageCount: number
|
||||
priority: number // 1 = highest priority
|
||||
contextual?: boolean // Shows only in certain contexts
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const fabButton = ref<HTMLElement | null>(null)
|
||||
const touchStartTime = ref(0)
|
||||
|
||||
// Usage tracking (in a real app, this would be persisted)
|
||||
const usageStats = ref<Record<string, number>>({
|
||||
'add-expense': 150, // 80% of usage
|
||||
'complete-chore': 28, // 15% of usage
|
||||
'add-to-list': 9, // 5% of usage
|
||||
'create-list': 5,
|
||||
'invite-member': 3,
|
||||
'quick-scan': 2,
|
||||
})
|
||||
|
||||
// Define all possible actions
|
||||
const allActions = computed<FABAction[]>(() => [
|
||||
{
|
||||
id: 'add-expense',
|
||||
label: 'Add Expense',
|
||||
icon: 'heroicons:currency-dollar',
|
||||
action: () => router.push('/expenses/new'),
|
||||
icon: 'receipt_long',
|
||||
priority: 1,
|
||||
usageCount: usageStats.value['add-expense'] || 0,
|
||||
action: () => {
|
||||
incrementUsage('add-expense')
|
||||
router.push('/expenses/new')
|
||||
closeFAB()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'complete-chore',
|
||||
label: 'Complete Chore',
|
||||
icon: 'heroicons:check-circle',
|
||||
action: () => router.push('/chores'),
|
||||
icon: 'task_alt',
|
||||
priority: 2,
|
||||
usageCount: usageStats.value['complete-chore'] || 0,
|
||||
action: () => {
|
||||
incrementUsage('complete-chore')
|
||||
router.push('/chores')
|
||||
closeFAB()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'add-to-list',
|
||||
label: 'Add to List',
|
||||
icon: 'heroicons:shopping-cart',
|
||||
action: () => router.push('/lists'),
|
||||
icon: 'add_shopping_cart',
|
||||
priority: 3,
|
||||
usageCount: usageStats.value['add-to-list'] || 0,
|
||||
action: () => {
|
||||
incrementUsage('add-to-list')
|
||||
router.push('/lists')
|
||||
closeFAB()
|
||||
}
|
||||
},
|
||||
]
|
||||
</script>
|
||||
{
|
||||
id: 'quick-scan',
|
||||
label: 'Scan Receipt',
|
||||
icon: 'qr_code_scanner',
|
||||
priority: 4,
|
||||
usageCount: usageStats.value['quick-scan'] || 0,
|
||||
action: () => {
|
||||
incrementUsage('quick-scan')
|
||||
// Emit event for receipt scanning
|
||||
emit('scan-receipt')
|
||||
closeFAB()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'create-list',
|
||||
label: 'New List',
|
||||
icon: 'playlist_add',
|
||||
priority: 5,
|
||||
usageCount: usageStats.value['create-list'] || 0,
|
||||
action: () => {
|
||||
incrementUsage('create-list')
|
||||
emit('create-list')
|
||||
closeFAB()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'invite-member',
|
||||
label: 'Invite Member',
|
||||
icon: 'person_add',
|
||||
priority: 6,
|
||||
usageCount: usageStats.value['invite-member'] || 0,
|
||||
action: () => {
|
||||
incrementUsage('invite-member')
|
||||
emit('invite-member')
|
||||
closeFAB()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Sort actions by usage count and priority
|
||||
const sortedActions = computed(() => {
|
||||
return allActions.value
|
||||
.slice() // Create a copy
|
||||
.sort((a, b) => {
|
||||
// Primary sort: usage count (descending)
|
||||
if (b.usageCount !== a.usageCount) {
|
||||
return b.usageCount - a.usageCount
|
||||
}
|
||||
// Secondary sort: priority (ascending, lower number = higher priority)
|
||||
return a.priority - b.priority
|
||||
})
|
||||
.slice(0, 4) // Show max 4 actions
|
||||
})
|
||||
|
||||
const toggleFAB = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
// Add haptic feedback if available
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closeFAB = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleTouchStart = () => {
|
||||
touchStartTime.value = Date.now()
|
||||
}
|
||||
|
||||
const handleActionClick = (action: FABAction) => {
|
||||
// Add haptic feedback
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(30)
|
||||
}
|
||||
|
||||
action.action()
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: `${action.label} opened`,
|
||||
})
|
||||
}
|
||||
|
||||
const incrementUsage = (actionId: string) => {
|
||||
usageStats.value[actionId] = (usageStats.value[actionId] || 0) + 1
|
||||
// In a real app, persist this to localStorage or API
|
||||
localStorage.setItem('fab-usage-stats', JSON.stringify(usageStats.value))
|
||||
}
|
||||
|
||||
// Calculate action position for radial layout
|
||||
const getActionPosition = (index: number) => {
|
||||
const totalActions = sortedActions.value.length
|
||||
const angleStep = (Math.PI * 0.6) / (totalActions - 1) // 108 degree arc
|
||||
const startAngle = -Math.PI * 0.3 // Start at -54 degrees
|
||||
const angle = startAngle + (angleStep * index)
|
||||
const radius = 80 // Distance from center
|
||||
|
||||
const x = Math.cos(angle) * radius
|
||||
const y = Math.sin(angle) * radius
|
||||
|
||||
return {
|
||||
transform: `translate(${x}px, ${y}px)`,
|
||||
transitionDelay: `${index * 50}ms`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle clicks outside FAB
|
||||
onClickOutside(fabButton, () => {
|
||||
if (isOpen.value) {
|
||||
closeFAB()
|
||||
}
|
||||
})
|
||||
|
||||
// Load usage stats on mount
|
||||
onMounted(() => {
|
||||
const savedStats = localStorage.getItem('fab-usage-stats')
|
||||
if (savedStats) {
|
||||
try {
|
||||
usageStats.value = { ...usageStats.value, ...JSON.parse(savedStats) }
|
||||
} catch (e) {
|
||||
console.warn('Failed to load FAB usage stats:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle escape key
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
closeFAB()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'scan-receipt': []
|
||||
'create-list': []
|
||||
'invite-member': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.universal-fab-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.fab-main {
|
||||
@apply w-14 h-14 rounded-full shadow-floating;
|
||||
@apply bg-primary-500 text-white;
|
||||
@apply flex items-center justify-center;
|
||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 focus-visible:ring-offset-2;
|
||||
@apply active:scale-95 transform-gpu;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fab-main:hover {
|
||||
@apply bg-primary-600 shadow-strong scale-105;
|
||||
}
|
||||
|
||||
.fab-main.is-open {
|
||||
@apply bg-error-500 rotate-45;
|
||||
}
|
||||
|
||||
.fab-main.is-open:hover {
|
||||
@apply bg-error-600;
|
||||
}
|
||||
|
||||
.fab-actions-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fab-action {
|
||||
@apply absolute w-12 h-12 rounded-full shadow-medium;
|
||||
@apply bg-white text-neutral-700 border border-neutral-200;
|
||||
@apply flex flex-col items-center justify-center;
|
||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50;
|
||||
@apply active:scale-90 transform-gpu;
|
||||
bottom: 14px;
|
||||
/* Center relative to main FAB */
|
||||
right: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.fab-action:hover {
|
||||
@apply bg-primary-50 text-primary-600 shadow-strong scale-110;
|
||||
}
|
||||
|
||||
.fab-action .material-icons {
|
||||
@apply text-lg leading-none;
|
||||
}
|
||||
|
||||
.fab-action-label {
|
||||
@apply absolute top-full mt-1 text-xs font-medium whitespace-nowrap;
|
||||
@apply bg-neutral-900 text-white px-2 py-1 rounded;
|
||||
@apply opacity-0 pointer-events-none;
|
||||
@apply transition-opacity duration-micro;
|
||||
}
|
||||
|
||||
.fab-action:hover .fab-action-label {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.fab-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.fab-main-enter-active,
|
||||
.fab-main-leave-active {
|
||||
transition: all 300ms ease-page;
|
||||
}
|
||||
|
||||
.fab-main-enter-from,
|
||||
.fab-main-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(180deg);
|
||||
}
|
||||
|
||||
.fab-icon-enter-active,
|
||||
.fab-icon-leave-active {
|
||||
transition: all 150ms ease-micro;
|
||||
}
|
||||
|
||||
.fab-icon-enter-from,
|
||||
.fab-icon-leave-to {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.8);
|
||||
}
|
||||
|
||||
.fab-actions-enter-active {
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.fab-actions-enter-active .fab-action {
|
||||
transition: all 300ms ease-page;
|
||||
}
|
||||
|
||||
.fab-actions-leave-active {
|
||||
transition: opacity 150ms ease-in;
|
||||
}
|
||||
|
||||
.fab-actions-leave-active .fab-action {
|
||||
transition: all 150ms ease-in;
|
||||
}
|
||||
|
||||
.fab-actions-enter-from,
|
||||
.fab-actions-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fab-actions-enter-from .fab-action,
|
||||
.fab-actions-leave-to .fab-action {
|
||||
opacity: 0;
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
}
|
||||
|
||||
.fab-backdrop-enter-active,
|
||||
.fab-backdrop-leave-active {
|
||||
transition: all 200ms ease-page;
|
||||
}
|
||||
|
||||
.fab-backdrop-enter-from,
|
||||
.fab-backdrop-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
.universal-fab-container {
|
||||
bottom: 90px;
|
||||
/* Above mobile navigation */
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.fab-action-label {
|
||||
display: none;
|
||||
/* Hide labels on mobile to reduce clutter */
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduce motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.fab-main,
|
||||
.fab-action,
|
||||
.fab-backdrop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.fab-main.is-open {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.fab-action:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
@ -1,56 +1,264 @@
|
||||
<template>
|
||||
<div class="p-4 rounded-lg bg-white dark:bg-neutral-800 shadow-sm w-full max-w-screen-md mx-auto">
|
||||
<Heading :level="2" class="mb-4">{{ $t('expenseOverview.title', 'Expense Overview') }}</Heading>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
|
||||
<span class="text-neutral-500">{{ $t('expenseOverview.totalExpenses', 'Total Expenses') }}</span>
|
||||
<span class="text-lg font-mono font-semibold">{{ formatCurrency(totalExpenses, currency) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
|
||||
<span class="text-neutral-500">{{ $t('expenseOverview.myBalance', 'My Balance') }}</span>
|
||||
<span class="text-lg font-mono font-semibold" :class="myBalance < 0 ? 'text-danger' : 'text-success'">{{
|
||||
formatCurrency(myBalance, currency) }}</span>
|
||||
</div>
|
||||
<Card class="expense-overview">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="skeleton-header"></div>
|
||||
<div class="skeleton-balance"></div>
|
||||
<div class="skeleton-tabs"></div>
|
||||
<div class="skeleton-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for chart -->
|
||||
<div class="mt-6 text-center text-neutral-400" v-if="!chartReady">
|
||||
{{ $t('expenseOverview.chartPlaceholder', 'Spending chart will appear here…') }}
|
||||
<div v-else-if="error" class="error-state">
|
||||
<Alert type="error" :message="error" />
|
||||
<Button @click="loadFinancials" class="mt-4">Retry</Button>
|
||||
</div>
|
||||
<canvas v-else ref="chartRef" class="w-full h-64" />
|
||||
</div>
|
||||
|
||||
<div v-else class="content-loaded">
|
||||
<div class="overview-header">
|
||||
<div class="balance-section">
|
||||
<span class="balance-label">Your Net Balance</span>
|
||||
<h2 class="net-balance" :class="balanceColorClass">
|
||||
{{ formatCurrency(netBalance, currency) }}
|
||||
</h2>
|
||||
<p class="balance-subtitle" :class="balanceColorClass">
|
||||
{{ balanceStatusText }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overall-spending">
|
||||
<span class="spending-label">Total Group Spending</span>
|
||||
<p class="spending-amount">
|
||||
{{ formatCurrency(totalGroupSpending, currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs v-model="selectedTab" class="mt-6">
|
||||
<div class="tab-list">
|
||||
<button v-for="tab in tabs" :key="tab.id"
|
||||
:class="['tab-item', { 'is-active': selectedTab === tab.id }]" @click="selectedTab = tab.id">
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panels">
|
||||
<div v-if="selectedTab === 'summary'" class="tab-panel">
|
||||
<ul v-if="debts.length > 0 || credits.length > 0" class="debt-credit-list">
|
||||
<li v-for="credit in credits" :key="`credit-${credit.user.id}`"
|
||||
class="list-item text-success">
|
||||
<span>{{ credit.user.name }} owes you</span>
|
||||
<span class="font-mono font-semibold">{{ formatCurrency(credit.amount, currency)
|
||||
}}</span>
|
||||
</li>
|
||||
<li v-for="debt in debts" :key="`debt-${debt.user.id}`" class="list-item text-danger">
|
||||
<span>You owe {{ debt.user.name }}</span>
|
||||
<span class="font-mono font-semibold">{{ formatCurrency(debt.amount, currency) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="empty-tab">
|
||||
<p>You are all settled up. Good job!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTab === 'transactions'" class="tab-panel">
|
||||
<!-- This would be a list of recent transactions -->
|
||||
<div class="empty-tab">
|
||||
<p>Transaction history coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTab === 'graph'" class="tab-panel">
|
||||
<!-- Placeholder for debt graph -->
|
||||
<div class="empty-tab">
|
||||
<p>Debt graph visualization coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Heading } from '@/components/ui'
|
||||
import { useExpenses } from '@/composables/useExpenses'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { Card, Button, Alert, Tabs } from '@/components/ui'
|
||||
import { API_ENDPOINTS } from '@/config/api-config'
|
||||
|
||||
// For future chart integration (e.g., Chart.js or ECharts)
|
||||
const chartRef = ref<HTMLCanvasElement | null>(null)
|
||||
const chartReady = ref(false)
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
groupId?: number | string
|
||||
}>()
|
||||
|
||||
const { expenses } = useExpenses()
|
||||
const currency = 'USD'
|
||||
// State
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const summary = ref<any>(null)
|
||||
|
||||
const totalExpenses = computed(() => expenses.value.reduce((sum: number, e: any) => sum + parseFloat(e.total_amount), 0))
|
||||
const myBalance = ref(0) // Will be provided by backend balances endpoint later
|
||||
type Tab = 'summary' | 'transactions' | 'graph';
|
||||
const selectedTab = ref<Tab>('summary')
|
||||
const currency = ref('USD')
|
||||
|
||||
function formatCurrency(amount: number, currency: string) {
|
||||
const tabs: { id: Tab; name: string }[] = [
|
||||
{ id: 'summary', name: 'Summary' },
|
||||
{ id: 'transactions', name: 'Transactions' },
|
||||
{ id: 'graph', name: 'Graph' },
|
||||
]
|
||||
|
||||
// API Call
|
||||
const loadFinancials = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
|
||||
} catch {
|
||||
return amount.toFixed(2) + ' ' + currency
|
||||
const endpoint = props.groupId
|
||||
? API_ENDPOINTS.FINANCIALS.GROUP_SUMMARY(props.groupId.toString())
|
||||
: API_ENDPOINTS.FINANCIALS.USER_SUMMARY
|
||||
const { data } = await apiClient.get(endpoint)
|
||||
summary.value = data
|
||||
if (data.currency) {
|
||||
currency.value = data.currency
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to load financial summary.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Computed Properties
|
||||
const netBalance = computed(() => summary.value?.net_balance || 0)
|
||||
const totalGroupSpending = computed(() => summary.value?.total_group_spending || 0)
|
||||
const debts = computed(() => summary.value?.debts || [])
|
||||
const credits = computed(() => summary.value?.credits || [])
|
||||
|
||||
const balanceColorClass = computed(() => {
|
||||
if (netBalance.value > 0) return 'text-success'
|
||||
if (netBalance.value < 0) return 'text-danger'
|
||||
return 'text-neutral-900'
|
||||
})
|
||||
|
||||
const balanceStatusText = computed(() => {
|
||||
if (netBalance.value > 0) return "You are owed money"
|
||||
if (netBalance.value < 0) return "You owe money"
|
||||
return "You are all settled up"
|
||||
})
|
||||
|
||||
// Formatters
|
||||
const formatCurrency = (amount: number, currencyCode: string) => {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode }).format(amount)
|
||||
} catch {
|
||||
return `${amount.toFixed(2)} ${currencyCode}`
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// TODO: load chart library dynamically & render
|
||||
chartReady.value = false
|
||||
loadFinancials()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tailwind handles styles */
|
||||
.expense-overview {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
/* Loading Skeleton */
|
||||
.loading-state {
|
||||
@apply animate-pulse space-y-6;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
@apply h-16 bg-neutral-200 rounded-lg;
|
||||
}
|
||||
|
||||
.skeleton-balance {
|
||||
@apply h-10 w-3/4 bg-neutral-200 rounded;
|
||||
}
|
||||
|
||||
.skeleton-tabs {
|
||||
@apply h-8 w-1/2 bg-neutral-200 rounded;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
@apply h-24 bg-neutral-200 rounded-lg;
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-state {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.overview-header {
|
||||
@apply flex justify-between items-start pb-4 border-b border-neutral-200;
|
||||
}
|
||||
|
||||
.balance-label,
|
||||
.spending-label {
|
||||
@apply text-sm text-neutral-600 mb-1;
|
||||
}
|
||||
|
||||
.net-balance {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
.balance-subtitle {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
.overall-spending {
|
||||
@apply text-right;
|
||||
}
|
||||
|
||||
.spending-amount {
|
||||
@apply text-xl font-semibold text-neutral-800;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tab-list {
|
||||
@apply flex border-b border-neutral-200;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@apply px-4 py-2 text-sm font-medium text-neutral-600 border-b-2 border-transparent;
|
||||
@apply hover:bg-neutral-100 hover:text-neutral-800 transition-colors;
|
||||
}
|
||||
|
||||
.tab-item.is-active {
|
||||
@apply text-primary border-primary;
|
||||
}
|
||||
|
||||
.tab-panels {
|
||||
@apply pt-6;
|
||||
}
|
||||
|
||||
/* Debt/Credit List */
|
||||
.debt-credit-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
@apply flex justify-between items-center p-3 bg-neutral-50 rounded-lg;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
@apply text-green-600;
|
||||
}
|
||||
|
||||
.list-item.text-success {
|
||||
@apply bg-green-50;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
@apply text-red-600;
|
||||
}
|
||||
|
||||
.list-item.text-danger {
|
||||
@apply bg-red-50;
|
||||
}
|
||||
|
||||
/* Empty State for Tabs */
|
||||
.empty-tab {
|
||||
@apply text-center text-neutral-500 py-8;
|
||||
}
|
||||
</style>
|
@ -1,42 +1,707 @@
|
||||
<template>
|
||||
<Dialog v-model="open" class="z-50">
|
||||
<div class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||
<Heading :level="3" class="mb-4">{{ $t('settlementFlow.title', 'Settle Expense') }}</Heading>
|
||||
<Dialog v-model="open" size="lg" class="z-50">
|
||||
<div class="settlement-flow">
|
||||
<!-- Header -->
|
||||
<header class="settlement-header">
|
||||
<div class="header-content">
|
||||
<BaseIcon name="heroicons:banknotes" class="header-icon text-primary-500" />
|
||||
<div>
|
||||
<Heading :level="3" class="settlement-title">Settlement Details</Heading>
|
||||
<p class="settlement-subtitle">Resolve outstanding balances</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" @click="open = false" class="close-button">
|
||||
<BaseIcon name="heroicons:x-mark" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<!-- Placeholder content -->
|
||||
<p class="text-sm text-neutral-500 mb-6">{{ $t('settlementFlow.placeholder', 'Settlement flow coming soon…')
|
||||
}}</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="ghost" @click="open = false">{{ $t('common.close', 'Close') }}</Button>
|
||||
<!-- Progress Steps -->
|
||||
<div class="steps-indicator">
|
||||
<div v-for="(step, index) in steps" :key="step.id" class="step-item" :class="{
|
||||
'step-active': currentStep === index,
|
||||
'step-completed': currentStep > index
|
||||
}">
|
||||
<div class="step-circle">
|
||||
<BaseIcon v-if="currentStep > index" name="heroicons:check" class="step-check" />
|
||||
<span v-else class="step-number">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="step-content">
|
||||
<!-- Step 1: Settlement Overview -->
|
||||
<div v-if="currentStep === 0" class="step-overview">
|
||||
<div class="debt-summary">
|
||||
<Card variant="soft" color="primary" class="summary-card">
|
||||
<div class="summary-header">
|
||||
<BaseIcon name="heroicons:calculator" />
|
||||
<h4>Settlement Summary</h4>
|
||||
</div>
|
||||
<div class="debt-breakdown">
|
||||
<div v-for="debt in debts" :key="debt.id" class="debt-item">
|
||||
<div class="debt-details">
|
||||
<span class="debt-from">{{ debt.fromUser.name }}</span>
|
||||
<BaseIcon name="heroicons:arrow-right" class="debt-arrow" />
|
||||
<span class="debt-to">{{ debt.toUser.name }}</span>
|
||||
</div>
|
||||
<div class="debt-amount" :class="getAmountClass(debt.amount)">
|
||||
{{ formatCurrency(debt.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="total-settlement">
|
||||
<span class="total-label">Total Settlement Amount:</span>
|
||||
<span class="total-amount">{{ formatCurrency(totalAmount) }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Line Items -->
|
||||
<div class="line-items">
|
||||
<h4 class="section-title">What this covers:</h4>
|
||||
<div class="items-list">
|
||||
<div v-for="item in settlementItems" :key="item.id" class="settlement-item">
|
||||
<div class="item-info">
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<span class="item-date">{{ formatDate(item.date) }}</span>
|
||||
</div>
|
||||
<div class="item-amount">{{ formatCurrency(item.amount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Payment Method -->
|
||||
<div v-if="currentStep === 1" class="step-payment">
|
||||
<h4 class="section-title">How was this settled?</h4>
|
||||
<div class="payment-methods">
|
||||
<div v-for="method in paymentMethods" :key="method.id" class="payment-option"
|
||||
:class="{ 'payment-selected': selectedPaymentMethod === method.id }"
|
||||
@click="selectedPaymentMethod = method.id">
|
||||
<BaseIcon :name="method.icon" class="payment-icon" />
|
||||
<div class="payment-details">
|
||||
<span class="payment-name">{{ method.name }}</span>
|
||||
<span class="payment-desc">{{ method.description }}</span>
|
||||
</div>
|
||||
<div class="payment-radio">
|
||||
<div class="radio-dot"
|
||||
:class="{ 'radio-selected': selectedPaymentMethod === method.id }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Payment Method -->
|
||||
<div class="custom-payment">
|
||||
<Input v-model="customPaymentMethod" label="Other payment method"
|
||||
placeholder="e.g., Cash, Bank Transfer, etc."
|
||||
:class="{ 'input-selected': selectedPaymentMethod === 'custom' }"
|
||||
@focus="selectedPaymentMethod = 'custom'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Verification -->
|
||||
<div v-if="currentStep === 2" class="step-verification">
|
||||
<h4 class="section-title">Settlement Verification</h4>
|
||||
<div class="verification-section">
|
||||
<Card variant="soft" color="warning" class="verification-card">
|
||||
<div class="verification-header">
|
||||
<BaseIcon name="heroicons:shield-check" />
|
||||
<span>Verification Required</span>
|
||||
</div>
|
||||
<p class="verification-text">
|
||||
Both parties need to confirm this settlement to prevent disputes.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Settlement Details Review -->
|
||||
<div class="settlement-review">
|
||||
<div class="review-item">
|
||||
<span class="review-label">Amount:</span>
|
||||
<span class="review-value">{{ formatCurrency(totalAmount) }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">Method:</span>
|
||||
<span class="review-value">{{ getSelectedPaymentMethodName() }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">Date:</span>
|
||||
<span class="review-value">{{ formatDate(new Date()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt Upload -->
|
||||
<div class="receipt-upload">
|
||||
<label class="upload-label">
|
||||
<BaseIcon name="heroicons:camera" />
|
||||
<span>Add Receipt (Optional)</span>
|
||||
<input type="file" accept="image/*" @change="handleReceiptUpload" hidden />
|
||||
</label>
|
||||
<div v-if="uploadedReceipt" class="uploaded-receipt">
|
||||
<img :src="uploadedReceipt" alt="Receipt" class="receipt-preview" />
|
||||
<Button variant="ghost" size="sm" @click="uploadedReceipt = null">
|
||||
<BaseIcon name="heroicons:x-mark" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<Textarea v-model="settlementNotes" label="Settlement Notes (Optional)"
|
||||
placeholder="Add any additional details about this settlement..." :rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<footer class="settlement-footer">
|
||||
<div class="footer-actions">
|
||||
<Button v-if="currentStep > 0" variant="outline" @click="prevStep" :disabled="processing">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:arrow-left" />
|
||||
</template>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div class="primary-actions">
|
||||
<Button v-if="currentStep < steps.length - 1" @click="nextStep" :disabled="!canProceed"
|
||||
class="next-button">
|
||||
Next Step
|
||||
<template #icon-right>
|
||||
<BaseIcon name="heroicons:arrow-right" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button v-else @click="confirmSettlement" :loading="processing" :disabled="!canConfirm"
|
||||
color="success" class="confirm-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:check-circle" />
|
||||
</template>
|
||||
Confirm Settlement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispute Option -->
|
||||
<div class="dispute-section">
|
||||
<Button variant="ghost" color="error" size="sm" @click="openDispute">
|
||||
<BaseIcon name="heroicons:exclamation-triangle" />
|
||||
Dispute This Settlement
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dispute Dialog -->
|
||||
<Dialog v-model="showDispute" size="md">
|
||||
<div class="dispute-dialog">
|
||||
<header class="dispute-header">
|
||||
<BaseIcon name="heroicons:exclamation-triangle" class="text-warning-500" />
|
||||
<Heading :level="4">Dispute Settlement</Heading>
|
||||
</header>
|
||||
<div class="dispute-content">
|
||||
<p class="dispute-description">
|
||||
If you disagree with this settlement, please provide details about the issue:
|
||||
</p>
|
||||
<Textarea v-model="disputeReason" label="Reason for dispute"
|
||||
placeholder="Please explain why you're disputing this settlement..." :rows="4" required />
|
||||
</div>
|
||||
<footer class="dispute-footer">
|
||||
<Button variant="outline" @click="showDispute = false">Cancel</Button>
|
||||
<Button color="warning" @click="submitDispute" :loading="submittingDispute"
|
||||
:disabled="!disputeReason.trim()">
|
||||
Submit Dispute
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import { Heading, Button } from '@/components/ui'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { Dialog, Heading, Button, Card, Input, Textarea } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import type { ExpenseSplit, Expense } from '@/types/expense'
|
||||
import { useExpenses } from '@/composables/useExpenses'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface Props {
|
||||
split: ExpenseSplit | null
|
||||
expense: Expense | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const open = defineModel<boolean>('modelValue', { default: false })
|
||||
const props = defineProps<{ split: ExpenseSplit | null, expense: Expense | null }>()
|
||||
|
||||
const form = reactive<{ amount: string }>({ amount: '' })
|
||||
|
||||
const notifications = useNotificationStore()
|
||||
const { settleExpenseSplit } = useExpenses()
|
||||
|
||||
async function handleSettle() {
|
||||
if (!props.split) return
|
||||
await settleExpenseSplit(props.split.id, {
|
||||
expense_split_id: props.split.id,
|
||||
paid_by_user_id: props.split.user_id,
|
||||
amount_paid: form.amount || '0',
|
||||
})
|
||||
open.value = false
|
||||
// Step management
|
||||
const currentStep = ref(0)
|
||||
const steps = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'payment', label: 'Payment Method' },
|
||||
{ id: 'verification', label: 'Verify & Confirm' }
|
||||
]
|
||||
|
||||
// Settlement data
|
||||
const totalAmount = ref(125.50) // This would come from props
|
||||
const debts = ref([
|
||||
{
|
||||
id: 1,
|
||||
fromUser: { name: 'You' },
|
||||
toUser: { name: 'Alice' },
|
||||
amount: 75.25
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fromUser: { name: 'You' },
|
||||
toUser: { name: 'Bob' },
|
||||
amount: 50.25
|
||||
}
|
||||
])
|
||||
|
||||
const settlementItems = ref([
|
||||
{ id: 1, name: 'Grocery Shopping - Whole Foods', date: new Date('2024-01-15'), amount: 89.50 },
|
||||
{ id: 2, name: 'Dinner at Italian Place', date: new Date('2024-01-18'), amount: 36.00 }
|
||||
])
|
||||
|
||||
// Payment method selection
|
||||
const selectedPaymentMethod = ref<string>('')
|
||||
const customPaymentMethod = ref('')
|
||||
|
||||
const paymentMethods = [
|
||||
{ id: 'venmo', name: 'Venmo', description: 'Quick digital transfer', icon: 'heroicons:credit-card' },
|
||||
{ id: 'paypal', name: 'PayPal', description: 'Secure online payment', icon: 'heroicons:credit-card' },
|
||||
{ id: 'zelle', name: 'Zelle', description: 'Bank-to-bank transfer', icon: 'heroicons:building-library' },
|
||||
{ id: 'cash', name: 'Cash', description: 'Physical cash payment', icon: 'heroicons:banknotes' }
|
||||
]
|
||||
|
||||
// Verification
|
||||
const uploadedReceipt = ref<string | null>(null)
|
||||
const settlementNotes = ref('')
|
||||
const processing = ref(false)
|
||||
|
||||
// Dispute handling
|
||||
const showDispute = ref(false)
|
||||
const disputeReason = ref('')
|
||||
const submittingDispute = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const canProceed = computed(() => {
|
||||
if (currentStep.value === 1) {
|
||||
return selectedPaymentMethod.value.length > 0 &&
|
||||
(selectedPaymentMethod.value !== 'custom' || customPaymentMethod.value.trim().length > 0)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
return selectedPaymentMethod.value.length > 0 &&
|
||||
(selectedPaymentMethod.value !== 'custom' || customPaymentMethod.value.trim().length > 0)
|
||||
})
|
||||
|
||||
// Methods
|
||||
function nextStep() {
|
||||
if (currentStep.value < steps.length - 1) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
function getAmountClass(amount: number) {
|
||||
return amount > 0 ? 'text-success-600' : 'text-error-600'
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return format(date, 'MMM d, yyyy')
|
||||
}
|
||||
|
||||
function getSelectedPaymentMethodName() {
|
||||
if (selectedPaymentMethod.value === 'custom') {
|
||||
return customPaymentMethod.value || 'Custom'
|
||||
}
|
||||
const method = paymentMethods.find(m => m.id === selectedPaymentMethod.value)
|
||||
return method?.name || 'Unknown'
|
||||
}
|
||||
|
||||
function handleReceiptUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedReceipt.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSettlement() {
|
||||
if (!props.split || !canConfirm.value) return
|
||||
|
||||
processing.value = true
|
||||
try {
|
||||
await settleExpenseSplit(props.split.id, {
|
||||
expense_split_id: props.split.id,
|
||||
paid_by_user_id: props.split.user_id,
|
||||
amount_paid: totalAmount.value.toString()
|
||||
})
|
||||
|
||||
notifications.addNotification({
|
||||
type: 'success',
|
||||
message: 'Settlement confirmed successfully!'
|
||||
})
|
||||
|
||||
open.value = false
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to confirm settlement. Please try again.'
|
||||
})
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDispute() {
|
||||
showDispute.value = true
|
||||
}
|
||||
|
||||
async function submitDispute() {
|
||||
if (!disputeReason.value.trim()) return
|
||||
|
||||
submittingDispute.value = true
|
||||
try {
|
||||
// API call to submit dispute
|
||||
notifications.addNotification({
|
||||
type: 'success',
|
||||
message: 'Dispute submitted. Both parties will be notified.'
|
||||
})
|
||||
|
||||
showDispute.value = false
|
||||
open.value = false
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to submit dispute. Please try again.'
|
||||
})
|
||||
} finally {
|
||||
submittingDispute.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentStep.value = 0
|
||||
selectedPaymentMethod.value = ''
|
||||
customPaymentMethod.value = ''
|
||||
uploadedReceipt.value = null
|
||||
settlementNotes.value = ''
|
||||
disputeReason.value = ''
|
||||
}
|
||||
|
||||
// Reset form when dialog closes
|
||||
watch(() => open.value, (newValue: boolean) => {
|
||||
if (!newValue) {
|
||||
setTimeout(resetForm, 300) // Delay to allow closing animation
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.settlement-flow {
|
||||
@apply bg-surface-primary p-0 rounded-lg w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.settlement-header {
|
||||
@apply flex items-center justify-between p-6 border-b border-border-primary bg-surface-soft;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@apply w-8 h-8;
|
||||
}
|
||||
|
||||
.settlement-title {
|
||||
@apply text-xl font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.settlement-subtitle {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply w-8 h-8 p-0 rounded-full;
|
||||
}
|
||||
|
||||
/* Steps Indicator */
|
||||
.steps-indicator {
|
||||
@apply flex items-center justify-center gap-8 p-6 bg-surface-soft border-b border-border-primary;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
@apply flex flex-col items-center gap-2 text-center;
|
||||
}
|
||||
|
||||
.step-circle {
|
||||
@apply w-10 h-10 rounded-full border-2 flex items-center justify-center font-medium text-sm;
|
||||
@apply border-border-secondary bg-surface-primary text-text-secondary;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.step-item.step-active .step-circle {
|
||||
@apply border-primary-500 bg-primary-500 text-white;
|
||||
}
|
||||
|
||||
.step-item.step-completed .step-circle {
|
||||
@apply border-success-500 bg-success-500 text-white;
|
||||
}
|
||||
|
||||
.step-check {
|
||||
@apply w-5 h-5;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
@apply text-xs font-medium text-text-secondary;
|
||||
}
|
||||
|
||||
.step-item.step-active .step-label {
|
||||
@apply text-primary-600;
|
||||
}
|
||||
|
||||
.step-item.step-completed .step-label {
|
||||
@apply text-success-600;
|
||||
}
|
||||
|
||||
/* Step Content */
|
||||
.step-content {
|
||||
@apply flex-1 overflow-y-auto p-6;
|
||||
}
|
||||
|
||||
/* Overview Step */
|
||||
.debt-summary {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
@apply flex items-center gap-3 mb-4;
|
||||
}
|
||||
|
||||
.summary-header h4 {
|
||||
@apply text-lg font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.debt-breakdown {
|
||||
@apply space-y-3 mb-4;
|
||||
}
|
||||
|
||||
.debt-item {
|
||||
@apply flex items-center justify-between p-3 bg-surface-primary rounded-lg;
|
||||
}
|
||||
|
||||
.debt-details {
|
||||
@apply flex items-center gap-3 text-sm;
|
||||
}
|
||||
|
||||
.debt-arrow {
|
||||
@apply w-4 h-4 text-text-tertiary;
|
||||
}
|
||||
|
||||
.debt-amount {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.total-settlement {
|
||||
@apply flex items-center justify-between pt-4 border-t border-border-secondary;
|
||||
@apply font-semibold text-lg;
|
||||
}
|
||||
|
||||
.line-items {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-lg font-semibold text-text-primary mb-4;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.settlement-item {
|
||||
@apply flex items-center justify-between p-3 bg-surface-soft rounded-lg;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
@apply font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.item-date {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
@apply font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
/* Payment Step */
|
||||
.payment-methods {
|
||||
@apply space-y-3 mb-6;
|
||||
}
|
||||
|
||||
.payment-option {
|
||||
@apply flex items-center gap-4 p-4 border border-border-secondary rounded-lg cursor-pointer;
|
||||
@apply transition-all duration-200 hover:border-primary-300 hover:bg-surface-soft;
|
||||
}
|
||||
|
||||
.payment-selected {
|
||||
@apply border-primary-500 bg-primary-50 dark:bg-primary-950/20;
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
@apply w-6 h-6 text-text-secondary;
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
@apply flex-1 flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
@apply font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.payment-desc {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.payment-radio {
|
||||
@apply w-5 h-5 rounded-full border-2 border-border-secondary flex items-center justify-center;
|
||||
}
|
||||
|
||||
.radio-dot {
|
||||
@apply w-2 h-2 rounded-full bg-transparent transition-colors duration-200;
|
||||
}
|
||||
|
||||
.radio-selected {
|
||||
@apply bg-primary-500;
|
||||
}
|
||||
|
||||
.custom-payment {
|
||||
@apply pt-4 border-t border-border-secondary;
|
||||
}
|
||||
|
||||
/* Verification Step */
|
||||
.verification-section {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.verification-card {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.verification-header {
|
||||
@apply flex items-center gap-3 mb-2;
|
||||
}
|
||||
|
||||
.verification-text {
|
||||
@apply text-sm text-text-secondary;
|
||||
}
|
||||
|
||||
.settlement-review {
|
||||
@apply space-y-3 p-4 bg-surface-soft rounded-lg;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.review-label {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
.review-value {
|
||||
@apply font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.receipt-upload {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
@apply flex items-center gap-3 p-4 border-2 border-dashed border-border-secondary rounded-lg cursor-pointer;
|
||||
@apply transition-colors duration-200 hover:border-primary-300 hover:bg-surface-soft;
|
||||
}
|
||||
|
||||
.uploaded-receipt {
|
||||
@apply flex items-center gap-3 p-3 bg-surface-soft rounded-lg;
|
||||
}
|
||||
|
||||
.receipt-preview {
|
||||
@apply w-16 h-16 object-cover rounded-md;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.settlement-footer {
|
||||
@apply p-6 border-t border-border-primary bg-surface-soft space-y-4;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.primary-actions {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.dispute-section {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
/* Dispute Dialog */
|
||||
.dispute-dialog {
|
||||
@apply bg-surface-primary p-6 rounded-lg w-full max-w-md;
|
||||
}
|
||||
|
||||
.dispute-header {
|
||||
@apply flex items-center gap-3 mb-4;
|
||||
}
|
||||
|
||||
.dispute-content {
|
||||
@apply space-y-4 mb-6;
|
||||
}
|
||||
|
||||
.dispute-description {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
.dispute-footer {
|
||||
@apply flex items-center justify-end gap-3;
|
||||
}
|
||||
</style>
|
492
fe/src/components/global/ConflictResolutionDialog.vue
Normal file
492
fe/src/components/global/ConflictResolutionDialog.vue
Normal file
@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<Dialog v-model="visible" size="lg">
|
||||
<div class="conflict-resolution-dialog">
|
||||
<!-- Header -->
|
||||
<div class="dialog-header">
|
||||
<div class="header-content">
|
||||
<div class="conflict-icon">
|
||||
<BaseIcon name="heroicons:exclamation-triangle-20-solid" class="warning-icon" />
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<Heading :level="3" class="dialog-title">Conflict Detected</Heading>
|
||||
<p class="dialog-description">
|
||||
{{ conflict?.action?.type }} conflict with server data. Choose how to resolve.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conflict-info">
|
||||
<span class="conflict-type">{{ conflict?.action }} Operation</span>
|
||||
<span class="conflict-time">{{ formatConflictTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparison View -->
|
||||
<div class="comparison-container">
|
||||
<div class="comparison-grid">
|
||||
<!-- Local Version -->
|
||||
<div class="version-panel local-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<BaseIcon name="heroicons:device-phone-mobile-20-solid" class="panel-icon" />
|
||||
<span class="panel-label">Your Changes</span>
|
||||
</div>
|
||||
<div class="version-timestamp">{{ formatLocalTime }}</div>
|
||||
</div>
|
||||
|
||||
<div class="version-content">
|
||||
<div v-if="localChanges.length > 0" class="changes-list">
|
||||
<div v-for="change in localChanges" :key="change.field" class="change-item">
|
||||
<div class="change-field">{{ change.field }}</div>
|
||||
<div class="change-value local-value">{{ change.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-changes">No specific changes detected</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button variant="solid" color="primary" @click="keepLocal" :loading="isResolving"
|
||||
class="resolution-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:check-20-solid" />
|
||||
</template>
|
||||
Keep Your Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Version -->
|
||||
<div class="version-panel server-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<BaseIcon name="heroicons:cloud-20-solid" class="panel-icon" />
|
||||
<span class="panel-label">Server Version</span>
|
||||
</div>
|
||||
<div class="version-timestamp">{{ formatServerTime }}</div>
|
||||
</div>
|
||||
|
||||
<div class="version-content">
|
||||
<div v-if="serverChanges.length > 0" class="changes-list">
|
||||
<div v-for="change in serverChanges" :key="change.field" class="change-item">
|
||||
<div class="change-field">{{ change.field }}</div>
|
||||
<div class="change-value server-value">{{ change.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-changes">No server changes</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<Button variant="outline" color="neutral" @click="keepServer" :loading="isResolving"
|
||||
class="resolution-button">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:arrow-down-tray-20-solid" />
|
||||
</template>
|
||||
Use Server Version
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff View Toggle -->
|
||||
<div class="diff-controls">
|
||||
<Button variant="ghost" size="sm" @click="showRawDiff = !showRawDiff" class="diff-toggle">
|
||||
<template #icon-left>
|
||||
<BaseIcon :name="showRawDiff ? 'heroicons:eye-slash-20-solid' : 'heroicons:eye-20-solid'" />
|
||||
</template>
|
||||
{{ showRawDiff ? 'Hide' : 'Show' }} Raw Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Raw Diff View -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="showRawDiff" class="raw-diff-container">
|
||||
<div class="diff-grid">
|
||||
<div class="diff-panel">
|
||||
<h5 class="diff-title">Local JSON</h5>
|
||||
<pre class="diff-content local-diff">{{ localJSON }}</pre>
|
||||
</div>
|
||||
<div class="diff-panel">
|
||||
<h5 class="diff-title">Server JSON</h5>
|
||||
<pre class="diff-content server-diff">{{ serverJSON }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="dialog-footer">
|
||||
<div class="footer-info">
|
||||
<BaseIcon name="heroicons:information-circle-20-solid" class="info-icon" />
|
||||
<span class="info-text">This conflict won't affect other users until resolved.</span>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<Button variant="ghost" color="neutral" @click="dismissConflict" :disabled="isResolving">
|
||||
Skip for Now
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" color="warning" @click="mergeManually" :disabled="isResolving">
|
||||
<template #icon-left>
|
||||
<BaseIcon name="heroicons:code-bracket-20-solid" />
|
||||
</template>
|
||||
Merge Manually
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { Dialog, Button, Card, Heading } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { useOfflineStore } from '@/stores/offline'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
|
||||
const offlineStore = useOfflineStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isResolving = ref(false)
|
||||
const showRawDiff = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => offlineStore.showConflictDialog,
|
||||
set: (val) => (offlineStore.showConflictDialog = val),
|
||||
})
|
||||
|
||||
const conflict = computed(() => offlineStore.currentConflict)
|
||||
|
||||
// Format timestamps
|
||||
const formatConflictTime = computed(() => {
|
||||
if (!conflict.value?.action?.timestamp) return 'Unknown time'
|
||||
return formatDistanceToNow(new Date(conflict.value.action.timestamp), { addSuffix: true })
|
||||
})
|
||||
|
||||
const formatLocalTime = computed(() => {
|
||||
if (!conflict.value?.localVersion.timestamp) return 'Local changes'
|
||||
return formatDistanceToNow(new Date(conflict.value.localVersion.timestamp), { addSuffix: true })
|
||||
})
|
||||
|
||||
const formatServerTime = computed(() => {
|
||||
if (!conflict.value?.serverVersion.timestamp) return 'Server version'
|
||||
return formatDistanceToNow(new Date(conflict.value.serverVersion.timestamp), { addSuffix: true })
|
||||
})
|
||||
|
||||
// JSON representations
|
||||
const localJSON = computed(() =>
|
||||
JSON.stringify(conflict.value?.localVersion.data ?? {}, null, 2)
|
||||
)
|
||||
|
||||
const serverJSON = computed(() =>
|
||||
JSON.stringify(conflict.value?.serverVersion.data ?? {}, null, 2)
|
||||
)
|
||||
|
||||
// Parse changes for better display
|
||||
const localChanges = computed(() => {
|
||||
if (!conflict.value?.localVersion.data) return []
|
||||
|
||||
const data = conflict.value.localVersion.data
|
||||
return Object.entries(data)
|
||||
.filter(([key, value]) => key !== 'id' && value != null)
|
||||
.map(([field, value]) => ({
|
||||
field: formatFieldName(field),
|
||||
value: formatFieldValue(value)
|
||||
}))
|
||||
})
|
||||
|
||||
const serverChanges = computed(() => {
|
||||
if (!conflict.value?.serverVersion.data) return []
|
||||
|
||||
const data = conflict.value.serverVersion.data
|
||||
return Object.entries(data)
|
||||
.filter(([key, value]) => key !== 'id' && value != null)
|
||||
.map(([field, value]) => ({
|
||||
field: formatFieldName(field),
|
||||
value: formatFieldValue(value)
|
||||
}))
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const formatFieldName = (field: string): string => {
|
||||
return field
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, str => str.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
const formatFieldValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return 'None'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Resolution actions
|
||||
const keepLocal = async () => {
|
||||
if (!conflict.value || isResolving.value) return
|
||||
|
||||
isResolving.value = true
|
||||
try {
|
||||
await offlineStore.handleConflictResolution({
|
||||
version: 'local',
|
||||
action: conflict.value.action
|
||||
})
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Conflict resolved with your changes'
|
||||
})
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to resolve conflict'
|
||||
})
|
||||
} finally {
|
||||
isResolving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const keepServer = async () => {
|
||||
if (!conflict.value || isResolving.value) return
|
||||
|
||||
isResolving.value = true
|
||||
try {
|
||||
await offlineStore.handleConflictResolution({
|
||||
version: 'server',
|
||||
action: conflict.value.action
|
||||
})
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Conflict resolved with server version'
|
||||
})
|
||||
} catch (error) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to resolve conflict'
|
||||
})
|
||||
} finally {
|
||||
isResolving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const mergeManually = async () => {
|
||||
// For now, just keep local and notify user
|
||||
await keepLocal()
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: 'Manual merge functionality coming soon. Using your changes for now.'
|
||||
})
|
||||
}
|
||||
|
||||
const dismissConflict = () => {
|
||||
visible.value = false
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'warning',
|
||||
message: 'Conflict postponed. Will retry on next sync.'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conflict-resolution-dialog {
|
||||
@apply max-w-4xl w-full;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@apply border-b border-border-primary pb-6 mb-6;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex items-start gap-4 mb-4;
|
||||
}
|
||||
|
||||
.conflict-icon {
|
||||
@apply flex-shrink-0 w-12 h-12 bg-warning-100 dark:bg-warning-900/30 rounded-lg;
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
@apply w-6 h-6 text-warning-600;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
@apply text-text-primary mb-2;
|
||||
}
|
||||
|
||||
.dialog-description {
|
||||
@apply text-text-secondary text-sm leading-relaxed;
|
||||
}
|
||||
|
||||
.conflict-info {
|
||||
@apply flex items-center gap-4 text-sm;
|
||||
}
|
||||
|
||||
.conflict-type {
|
||||
@apply px-3 py-1 bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-300;
|
||||
@apply rounded-full font-medium;
|
||||
}
|
||||
|
||||
.conflict-time {
|
||||
@apply text-text-tertiary;
|
||||
}
|
||||
|
||||
/* Comparison View */
|
||||
.comparison-container {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.comparison-grid {
|
||||
@apply grid grid-cols-1 lg:grid-cols-2 gap-6;
|
||||
}
|
||||
|
||||
.version-panel {
|
||||
@apply border border-border-primary rounded-lg bg-surface-primary;
|
||||
}
|
||||
|
||||
.local-panel {
|
||||
@apply border-primary-300 bg-primary-50/50 dark:bg-primary-900/10;
|
||||
}
|
||||
|
||||
.server-panel {
|
||||
@apply border-neutral-300 bg-neutral-50/50 dark:bg-neutral-900/10;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex items-center justify-between p-4 border-b border-border-primary;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.panel-icon {
|
||||
@apply w-5 h-5 text-text-secondary;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
@apply font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.version-timestamp {
|
||||
@apply text-xs text-text-tertiary;
|
||||
}
|
||||
|
||||
.version-content {
|
||||
@apply p-4 min-h-[120px];
|
||||
}
|
||||
|
||||
.changes-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.change-field {
|
||||
@apply text-xs font-medium text-text-secondary uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
@apply text-sm font-mono bg-surface-secondary px-3 py-2 rounded border;
|
||||
@apply break-words;
|
||||
}
|
||||
|
||||
.local-value {
|
||||
@apply border-primary-200 bg-primary-50 dark:bg-primary-900/20;
|
||||
}
|
||||
|
||||
.server-value {
|
||||
@apply border-neutral-200 bg-neutral-50 dark:bg-neutral-900/20;
|
||||
}
|
||||
|
||||
.no-changes {
|
||||
@apply text-text-tertiary text-sm italic text-center py-8;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply p-4 border-t border-border-primary;
|
||||
}
|
||||
|
||||
.resolution-button {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
/* Diff Controls */
|
||||
.diff-controls {
|
||||
@apply flex justify-center;
|
||||
}
|
||||
|
||||
.diff-toggle {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
|
||||
/* Raw Diff */
|
||||
.raw-diff-container {
|
||||
@apply border border-border-primary rounded-lg bg-surface-secondary;
|
||||
}
|
||||
|
||||
.diff-grid {
|
||||
@apply grid grid-cols-1 lg:grid-cols-2;
|
||||
}
|
||||
|
||||
.diff-panel {
|
||||
@apply border-r border-border-primary last:border-r-0;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
@apply px-4 py-3 border-b border-border-primary bg-surface-secondary;
|
||||
@apply text-sm font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
@apply p-4 text-xs font-mono overflow-auto max-h-64;
|
||||
@apply text-text-primary bg-surface-primary;
|
||||
}
|
||||
|
||||
.local-diff {
|
||||
@apply bg-primary-50/30 dark:bg-primary-900/10;
|
||||
}
|
||||
|
||||
.server-diff {
|
||||
@apply bg-neutral-50/30 dark:bg-neutral-900/10;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.dialog-footer {
|
||||
@apply flex items-center justify-between pt-6 border-t border-border-primary mt-6;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
@apply flex items-center gap-2 text-sm text-text-tertiary;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
@apply opacity-0 transform -translate-y-4;
|
||||
}
|
||||
</style>
|
@ -13,7 +13,9 @@
|
||||
@checkbox-change="(item, checked) => $emit('checkbox-change', item, checked)"
|
||||
@update-price="$emit('update-price', item)" @start-edit="$emit('start-edit', item)"
|
||||
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
|
||||
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event"
|
||||
@update-quantity="(item, qty) => $emit('update-quantity', item, qty)"
|
||||
@mark-bought="handleMarkBought" @update:editName="item.editName = $event"
|
||||
@update:editQuantity="item.editQuantity = $event"
|
||||
@update:editCategoryId="item.editCategoryId = $event"
|
||||
@update:priceInput="item.priceInput = $event" :list="list" />
|
||||
</template>
|
||||
@ -36,14 +38,14 @@
|
||||
</Listbox>
|
||||
<Button type="submit" size="sm" :disabled="!newItem.name.trim()">{{ t('listDetailPage.buttons.add',
|
||||
'Add')
|
||||
}}</Button>
|
||||
}}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineProps, defineEmits } from 'vue';
|
||||
import { ref, computed, defineProps, defineEmits, onMounted } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@ -56,6 +58,10 @@ import { Listbox } from '@headlessui/vue'; // This might not be needed if we mak
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import Input from '@/components/ui/Input.vue';
|
||||
import BaseIcon from '@/components/BaseIcon.vue';
|
||||
import PurchaseConfirmationDialog from './PurchaseConfirmationDialog.vue'
|
||||
import { useOptimisticUpdates } from '@/composables/useOptimisticUpdates'
|
||||
import { useSocket } from '@/composables/useSocket'
|
||||
import { enqueue } from '@/utils/offlineQueue'
|
||||
|
||||
interface ItemWithUI extends Item {
|
||||
updating: boolean;
|
||||
@ -112,6 +118,8 @@ const emit = defineEmits([
|
||||
'handle-drag-end',
|
||||
'update:newItemName',
|
||||
'update:newItemCategoryId',
|
||||
'update-quantity',
|
||||
'mark-bought',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
@ -122,10 +130,13 @@ const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
|
||||
value: opt.value === null ? '' : opt.value
|
||||
})));
|
||||
|
||||
// Optimistic updates wrapper so UI responds instantly
|
||||
const { data: optimisticItems, mutate: optimisticMutate } = useOptimisticUpdates<ItemWithUI>(props.items)
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
|
||||
|
||||
props.items.forEach(item => {
|
||||
optimisticItems.value.forEach(item => {
|
||||
const categoryId = item.category_id;
|
||||
const category = props.categories.find(c => c.id === categoryId);
|
||||
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
|
||||
@ -181,6 +192,27 @@ defineExpose({
|
||||
focusNewItemInput
|
||||
});
|
||||
|
||||
// === Real-time sync ===
|
||||
const { on: onSocket, emit: wsEmit } = useSocket()
|
||||
|
||||
onMounted(() => {
|
||||
onSocket('lists:item:update', (payload: any) => {
|
||||
// Basic handler: update local optimistic list when other user updates.
|
||||
if (!payload?.id) return
|
||||
optimisticMutate((draft) => draft.map((i) => (i.id === payload.id ? { ...i, ...payload } : i)))
|
||||
})
|
||||
})
|
||||
|
||||
function handleMarkBought(item: ItemWithUI) {
|
||||
// Optimistic update local state
|
||||
optimisticMutate((draft) => draft.map((i) => (i.id === item.id ? { ...i, is_complete: true } : i)))
|
||||
wsEmit('lists:item:update', { id: item.id, action: 'bought' })
|
||||
|
||||
if (!props.isOnline) {
|
||||
enqueue({ type: 'item-bought', payload: { id: item.id } })
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -1,16 +1,8 @@
|
||||
<template>
|
||||
<div class="list-item-wrapper" :class="{ 'is-complete': item.is_complete }">
|
||||
<div class="list-item-content">
|
||||
<div class="drag-handle" v-if="isOnline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="5" r="1"></circle>
|
||||
<circle cx="9" cy="19" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="5" r="1"></circle>
|
||||
<circle cx="15" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
<div class="drag-handle text-neutral-400 dark:text-neutral-500" v-if="isOnline">
|
||||
<BaseIcon name="heroicons:bars-2" class="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div class="item-main-content">
|
||||
@ -30,15 +22,25 @@
|
||||
Unclaim
|
||||
</VButton>
|
||||
|
||||
<VInput v-if="item.is_complete" type="number" :model-value="item.price"
|
||||
@update:model-value="$emit('update-price', item, $event)" placeholder="Price" class="price-input" />
|
||||
<VButton @click="$emit('edit-item', item)" variant="ghost" size="sm" aria-label="Edit Item">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</VButton>
|
||||
<Input v-if="item.is_complete" type="number" :model-value="item.price"
|
||||
@update:modelValue="$emit('update-price', item, $event)" placeholder="0.00" class="w-20" />
|
||||
<Button variant="ghost" size="sm" @click="$emit('edit-item', item)" aria-label="Edit Item">
|
||||
<BaseIcon name="heroicons:pencil-square" />
|
||||
</Button>
|
||||
<!-- Quantity stepper -->
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<Button variant="outline" size="xs" @click="decreaseQty" :disabled="(item.quantity ?? 1) <= 1">
|
||||
<BaseIcon name="heroicons:minus-small" />
|
||||
</Button>
|
||||
<span class="min-w-[1.5rem] text-center text-sm">{{ item.quantity ?? 1 }}</span>
|
||||
<Button variant="outline" size="xs" @click="increaseQty">
|
||||
<BaseIcon name="heroicons:plus-small" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Mark bought button appears when claimed_by_me and not purchased -->
|
||||
<Button v-if="showMarkBought" variant="solid" color="success" size="sm" @click="markBought">
|
||||
<BaseIcon name="heroicons:shopping-cart" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -52,9 +54,11 @@ import type { List } from '@/types/list';
|
||||
import { useListsStore } from '@/stores/listsStore';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import VCheckbox from '@/components/valerie/VCheckbox.vue';
|
||||
import VButton from '@/components/valerie/VButton.vue';
|
||||
import VInput from '@/components/valerie/VInput.vue';
|
||||
import Checkbox from '@/components/ui/Switch.vue';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import Input from '@/components/ui/Input.vue';
|
||||
import BaseIcon from '@/components/BaseIcon.vue';
|
||||
import { useSocket } from '@/composables/useSocket';
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
@ -68,15 +72,13 @@ const props = defineProps({
|
||||
isOnline: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete-item', 'checkbox-change', 'update-price', 'edit-item']);
|
||||
const emit = defineEmits(['delete-item', 'checkbox-change', 'update-price', 'edit-item', 'update-quantity', 'mark-bought']);
|
||||
|
||||
const listsStore = useListsStore();
|
||||
const authStore = useAuthStore();
|
||||
const currentUser = computed(() => authStore.user);
|
||||
|
||||
const canClaim = computed(() => {
|
||||
return props.list.group_id && !props.item.is_complete && !props.item.claimed_by_user_id;
|
||||
});
|
||||
const canClaim = computed(() => props.list.group_id && !props.item.is_complete && !props.item.claimed_by_user_id);
|
||||
|
||||
const canUnclaim = computed(() => {
|
||||
return props.item.claimed_by_user_id === currentUser.value?.id;
|
||||
@ -92,15 +94,29 @@ const claimStatus = computed(() => {
|
||||
const handleClaim = () => {
|
||||
if (!props.list.group_id) return; // Should not happen if button is shown, but good practice
|
||||
listsStore.claimItem(props.item.id);
|
||||
socket.emit('lists:item:update', { id: props.item.id, action: 'claim' });
|
||||
};
|
||||
|
||||
const handleUnclaim = () => {
|
||||
listsStore.unclaimItem(props.item.id);
|
||||
socket.emit('lists:item:update', { id: props.item.id, action: 'unclaim' });
|
||||
};
|
||||
|
||||
const onCheckboxChange = (checked: boolean) => {
|
||||
emit('checkbox-change', props.item, checked);
|
||||
};
|
||||
|
||||
const socket = useSocket();
|
||||
|
||||
const showMarkBought = computed(() => props.item.claimed_by_user_id === currentUser.value?.id && !props.item.is_complete);
|
||||
|
||||
const markBought = () => {
|
||||
emit('mark-bought', props.item);
|
||||
socket.emit('lists:item:update', { id: props.item.id, action: 'bought' });
|
||||
};
|
||||
|
||||
const increaseQty = () => emit('update-quantity', props.item, (props.item.quantity ?? 1) + 1);
|
||||
const decreaseQty = () => emit('update-quantity', props.item, (props.item.quantity ?? 1) - 1);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
545
fe/src/components/list-detail/ListItemComposer.vue
Normal file
545
fe/src/components/list-detail/ListItemComposer.vue
Normal file
@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="item-composer">
|
||||
<!-- Quick Add Bar -->
|
||||
<div class="composer-container" :class="{ 'composer-focused': isFocused, 'composer-listening': isListening }">
|
||||
<div class="input-section">
|
||||
<div class="input-wrapper">
|
||||
<BaseIcon name="heroicons:plus-20-solid" class="add-icon" />
|
||||
<input ref="inputRef" v-model="itemText" type="text"
|
||||
placeholder="Add item... (e.g., 2x Organic Milk)" class="item-input" @focus="handleFocus"
|
||||
@blur="handleBlur" @keydown="handleKeydown" @input="handleInput" autocomplete="off" />
|
||||
<div class="input-actions">
|
||||
<!-- Voice Input Button -->
|
||||
<Button v-if="supportsSpeechRecognition" variant="ghost" size="sm"
|
||||
:class="{ 'listening': isListening }" @click="toggleVoiceInput">
|
||||
<BaseIcon
|
||||
:name="isListening ? 'heroicons:stop-20-solid' : 'heroicons:microphone-20-solid'" />
|
||||
</Button>
|
||||
|
||||
<!-- Add Button -->
|
||||
<Button variant="solid" color="primary" size="sm" :disabled="!itemText.trim()" @click="addItem">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smart Suggestions Dropdown -->
|
||||
<Transition name="dropdown">
|
||||
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="suggestions-dropdown">
|
||||
<div class="suggestions-header">
|
||||
<span class="suggestions-title">Suggestions</span>
|
||||
<span class="suggestions-count">{{ filteredSuggestions.length }}</span>
|
||||
</div>
|
||||
<div class="suggestions-list">
|
||||
<button v-for="(suggestion, index) in filteredSuggestions" :key="suggestion.id"
|
||||
:class="['suggestion-item', { 'highlighted': highlightedIndex === index }]"
|
||||
@click="selectSuggestion(suggestion)" @mouseenter="highlightedIndex = index">
|
||||
<div class="suggestion-content">
|
||||
<div class="suggestion-main">
|
||||
<span class="suggestion-name">{{ suggestion.name }}</span>
|
||||
<span v-if="suggestion.brand" class="suggestion-brand">{{ suggestion.brand
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="suggestion-meta">
|
||||
<span v-if="suggestion.category" class="suggestion-category">{{
|
||||
suggestion.category }}</span>
|
||||
<span v-if="suggestion.lastPrice" class="suggestion-price">${{
|
||||
suggestion.lastPrice }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="suggestion-stats">
|
||||
<span class="suggestion-frequency">{{ suggestion.frequency }}x</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Smart Context Bar -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="isFocused && !itemText" class="context-bar">
|
||||
<div class="context-tabs">
|
||||
<button v-for="tab in contextTabs" :key="tab.id"
|
||||
:class="['context-tab', { 'active': activeTab === tab.id }]" @click="activeTab = tab.id">
|
||||
<BaseIcon :name="tab.icon" class="tab-icon" />
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
<span v-if="tab.count" class="tab-count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="context-content">
|
||||
<!-- Recent Items -->
|
||||
<div v-if="activeTab === 'recent'" class="quick-add-grid">
|
||||
<button v-for="item in recentItems" :key="item.id" class="quick-add-chip"
|
||||
@click="selectSuggestion(item)">
|
||||
<span class="chip-text">{{ item.name }}</span>
|
||||
<BaseIcon name="heroicons:plus-20-solid" class="chip-icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div v-else-if="activeTab === 'categories'" class="categories-grid">
|
||||
<button v-for="category in popularCategories" :key="category.id" class="category-chip"
|
||||
@click="setCategory(category)">
|
||||
<BaseIcon :name="category.icon" class="category-icon" />
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Voice Commands -->
|
||||
<div v-else-if="activeTab === 'voice'" class="voice-guide">
|
||||
<div class="voice-examples">
|
||||
<p class="voice-instruction">Try saying:</p>
|
||||
<ul class="voice-commands">
|
||||
<li>"Add two pounds of ground beef"</li>
|
||||
<li>"Three bottles of olive oil"</li>
|
||||
<li>"Organic bananas"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Voice Feedback -->
|
||||
<Transition name="fade">
|
||||
<div v-if="isListening" class="voice-feedback">
|
||||
<div class="voice-indicator">
|
||||
<div class="voice-wave"></div>
|
||||
<div class="voice-wave"></div>
|
||||
<div class="voice-wave"></div>
|
||||
</div>
|
||||
<span class="voice-text">{{ voiceStatus }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
interface ItemSuggestion {
|
||||
id: string
|
||||
name: string
|
||||
brand?: string
|
||||
category?: string
|
||||
lastPrice?: number
|
||||
frequency: number
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
listId: string
|
||||
storeLocation?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'item-added': [item: any]
|
||||
}>()
|
||||
|
||||
// Component state
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const itemText = ref('')
|
||||
const isFocused = ref(false)
|
||||
const isListening = ref(false)
|
||||
const voiceStatus = ref('Listening...')
|
||||
const activeTab = ref<'recent' | 'categories' | 'voice'>('recent')
|
||||
const highlightedIndex = ref(-1)
|
||||
|
||||
// Voice recognition basic support check
|
||||
const supportsSpeechRecognition = computed(() => false) // Simplified for now
|
||||
|
||||
// Suggestions and context
|
||||
const suggestions = ref<ItemSuggestion[]>([])
|
||||
const showSuggestions = computed(() => isFocused.value && itemText.value.length > 0)
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
if (!itemText.value) return []
|
||||
|
||||
const searchTerm = itemText.value.toLowerCase()
|
||||
return suggestions.value
|
||||
.filter(s => s.name.toLowerCase().includes(searchTerm))
|
||||
.sort((a, b) => b.frequency - a.frequency)
|
||||
.slice(0, 6)
|
||||
})
|
||||
|
||||
const recentItems = computed(() =>
|
||||
suggestions.value
|
||||
.sort((a, b) => b.frequency - a.frequency)
|
||||
.slice(0, 8)
|
||||
)
|
||||
|
||||
const popularCategories = computed<Category[]>(() => [
|
||||
{ id: 'produce', name: 'Produce', icon: 'heroicons:heart-20-solid' },
|
||||
{ id: 'dairy', name: 'Dairy', icon: 'heroicons:cube-20-solid' },
|
||||
{ id: 'meat', name: 'Meat & Seafood', icon: 'heroicons:fire-20-solid' },
|
||||
{ id: 'pantry', name: 'Pantry', icon: 'heroicons:archive-box-20-solid' },
|
||||
{ id: 'frozen', name: 'Frozen', icon: 'heroicons:snowflake-20-solid' },
|
||||
{ id: 'household', name: 'Household', icon: 'heroicons:home-20-solid' }
|
||||
])
|
||||
|
||||
const contextTabs = computed(() => [
|
||||
{ id: 'recent' as const, label: 'Recent', icon: 'heroicons:clock-20-solid', count: recentItems.value.length },
|
||||
{ id: 'categories' as const, label: 'Categories', icon: 'heroicons:tag-20-solid' },
|
||||
{ id: 'voice' as const, label: 'Voice', icon: 'heroicons:microphone-20-solid' }
|
||||
])
|
||||
|
||||
// Event handlers
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true
|
||||
loadSuggestions()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay to allow clicks on suggestions
|
||||
setTimeout(() => {
|
||||
isFocused.value = false
|
||||
highlightedIndex.value = -1
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!showSuggestions.value) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + 1,
|
||||
filteredSuggestions.value.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (highlightedIndex.value >= 0) {
|
||||
selectSuggestion(filteredSuggestions.value[highlightedIndex.value])
|
||||
} else {
|
||||
addItem()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
isFocused.value = false
|
||||
inputRef.value?.blur()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVoiceInput = () => {
|
||||
// Voice input would be implemented here
|
||||
console.log('Voice input toggle')
|
||||
}
|
||||
|
||||
const selectSuggestion = (suggestion: ItemSuggestion) => {
|
||||
itemText.value = suggestion.name
|
||||
addItem()
|
||||
}
|
||||
|
||||
const setCategory = (category: Category) => {
|
||||
// Could set a category filter or prefix
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
const addItem = async () => {
|
||||
if (!itemText.value.trim()) return
|
||||
|
||||
const itemData = {
|
||||
name: itemText.value.trim(),
|
||||
quantity: 1,
|
||||
list_id: props.listId,
|
||||
claimed_by_user_id: null
|
||||
}
|
||||
|
||||
// Emit the item for parent to handle
|
||||
emit('item-added', itemData)
|
||||
itemText.value = ''
|
||||
|
||||
// Update suggestions frequency
|
||||
updateSuggestionFrequency(itemData.name)
|
||||
}
|
||||
|
||||
const loadSuggestions = async () => {
|
||||
// Mock suggestions for now
|
||||
suggestions.value = [
|
||||
{ id: '1', name: 'Organic Milk', category: 'Dairy', frequency: 5, lastPrice: 4.99 },
|
||||
{ id: '2', name: 'Bananas', category: 'Produce', frequency: 8, lastPrice: 2.49 },
|
||||
{ id: '3', name: 'Bread', category: 'Bakery', frequency: 3, lastPrice: 3.49 },
|
||||
{ id: '4', name: 'Eggs', category: 'Dairy', frequency: 4, lastPrice: 5.99 },
|
||||
{ id: '5', name: 'Chicken Breast', category: 'Meat', frequency: 2, lastPrice: 8.99 }
|
||||
]
|
||||
}
|
||||
|
||||
const updateSuggestionFrequency = (itemName: string) => {
|
||||
const existing = suggestions.value.find(s => s.name.toLowerCase() === itemName.toLowerCase())
|
||||
if (existing) {
|
||||
existing.frequency++
|
||||
} else {
|
||||
suggestions.value.push({
|
||||
id: `suggestion-${Date.now()}`,
|
||||
name: itemName,
|
||||
frequency: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadSuggestions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-composer {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.composer-container {
|
||||
@apply bg-surface-primary border border-border-primary rounded-lg transition-all duration-micro;
|
||||
@apply hover:border-border-hover;
|
||||
}
|
||||
|
||||
.composer-focused {
|
||||
@apply border-primary-500 ring-2 ring-primary-500/20;
|
||||
}
|
||||
|
||||
.composer-listening {
|
||||
@apply border-accent-500 ring-2 ring-accent-500/20;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@apply flex items-center gap-3 p-4;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
@apply w-5 h-5 text-text-secondary flex-shrink-0;
|
||||
}
|
||||
|
||||
.item-input {
|
||||
@apply flex-1 bg-transparent border-0 outline-none text-text-primary placeholder-text-tertiary;
|
||||
@apply text-sm leading-5;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.listening {
|
||||
@apply text-accent-500 bg-accent-50 dark:bg-accent-500/10;
|
||||
}
|
||||
|
||||
/* Suggestions dropdown */
|
||||
.suggestions-dropdown {
|
||||
@apply absolute top-full left-0 right-0 bg-surface-primary border border-border-primary rounded-lg shadow-strong;
|
||||
@apply mt-2 py-2 z-50 max-h-80 overflow-y-auto;
|
||||
}
|
||||
|
||||
.suggestions-header {
|
||||
@apply flex items-center justify-between px-4 py-2 border-b border-border-primary;
|
||||
}
|
||||
|
||||
.suggestions-title {
|
||||
@apply text-xs font-medium text-text-secondary uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.suggestions-count {
|
||||
@apply text-xs text-text-tertiary;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
@apply w-full flex items-center justify-between px-4 py-3 hover:bg-surface-secondary;
|
||||
@apply transition-colors duration-micro cursor-pointer;
|
||||
}
|
||||
|
||||
.suggestion-item.highlighted {
|
||||
@apply bg-primary-50 dark:bg-primary-500/10;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.suggestion-main {
|
||||
@apply flex items-center gap-2 mb-1;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
@apply font-medium text-text-primary truncate;
|
||||
}
|
||||
|
||||
.suggestion-brand {
|
||||
@apply text-xs text-text-secondary bg-surface-tertiary px-2 py-0.5 rounded;
|
||||
}
|
||||
|
||||
.suggestion-meta {
|
||||
@apply flex items-center gap-3 text-xs text-text-tertiary;
|
||||
}
|
||||
|
||||
.suggestion-stats {
|
||||
@apply text-xs text-text-tertiary font-medium;
|
||||
}
|
||||
|
||||
/* Context bar */
|
||||
.context-bar {
|
||||
@apply border-t border-border-primary bg-surface-secondary;
|
||||
}
|
||||
|
||||
.context-tabs {
|
||||
@apply flex border-b border-border-primary;
|
||||
}
|
||||
|
||||
.context-tab {
|
||||
@apply flex items-center gap-2 px-4 py-3 text-sm font-medium text-text-secondary;
|
||||
@apply hover:text-text-primary hover:bg-surface-primary transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.context-tab.active {
|
||||
@apply text-primary-600 bg-primary-50 border-b-2 border-primary-500;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
@apply text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
/* Quick add chips */
|
||||
.quick-add-grid {
|
||||
@apply grid grid-cols-2 sm:grid-cols-4 gap-2;
|
||||
}
|
||||
|
||||
.quick-add-chip {
|
||||
@apply flex items-center justify-between px-3 py-2 bg-surface-primary border border-border-primary;
|
||||
@apply rounded-md hover:border-primary-300 hover:bg-primary-50 transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
@apply text-sm text-text-primary truncate;
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
@apply w-4 h-4 text-text-tertiary ml-2;
|
||||
}
|
||||
|
||||
/* Categories */
|
||||
.categories-grid {
|
||||
@apply grid grid-cols-3 sm:grid-cols-6 gap-3;
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
@apply flex flex-col items-center gap-2 p-3 bg-surface-primary border border-border-primary;
|
||||
@apply rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
@apply w-6 h-6 text-text-secondary;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
@apply text-xs font-medium text-text-primary text-center;
|
||||
}
|
||||
|
||||
/* Voice feedback */
|
||||
.voice-feedback {
|
||||
@apply flex items-center gap-3 mt-4 p-4 bg-accent-50 dark:bg-accent-500/10 rounded-lg;
|
||||
}
|
||||
|
||||
.voice-indicator {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.voice-wave {
|
||||
@apply w-1 h-4 bg-accent-500 rounded-full;
|
||||
animation: voice-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-wave:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.voice-wave:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.voice-text {
|
||||
@apply text-sm font-medium text-accent-700 dark:text-accent-300;
|
||||
}
|
||||
|
||||
/* Voice guide */
|
||||
.voice-guide {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.voice-instruction {
|
||||
@apply text-sm font-medium text-text-primary mb-3;
|
||||
}
|
||||
|
||||
.voice-commands {
|
||||
@apply text-sm text-text-secondary space-y-1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
@apply opacity-0 transform scale-95;
|
||||
}
|
||||
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
@apply opacity-0 transform -translate-y-2;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
@apply transition-opacity duration-200;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
@keyframes voice-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
32
fe/src/components/list-detail/PurchaseConfirmationDialog.vue
Normal file
32
fe/src/components/list-detail/PurchaseConfirmationDialog.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<Dialog v-model="open" size="sm">
|
||||
<div class="p-6 space-y-4 w-full max-w-sm">
|
||||
<Heading :level="4">Confirm Purchase</Heading>
|
||||
<p class="text-sm text-text-secondary">Did you buy these items?</p>
|
||||
<ul class="list-disc list-inside text-sm">
|
||||
<li v-for="i in items" :key="i.id">{{ i.name }} × {{ i.quantity }}</li>
|
||||
</ul>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="outline" @click="open = false">Cancel</Button>
|
||||
<Button color="success" @click="confirm">Confirm</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Dialog, Button, Heading } from '@/components/ui'
|
||||
|
||||
interface ItemLite { id: number | string; name: string; quantity?: number | string }
|
||||
|
||||
const props = defineProps<{ items: ItemLite[] }>()
|
||||
const emit = defineEmits<{ (e: 'confirm'): void }>()
|
||||
|
||||
const open = ref(true)
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<button :type="type" :class="buttonClasses" :disabled="disabled">
|
||||
<button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
|
||||
<slot name="icon-left" />
|
||||
<slot />
|
||||
<slot name="icon-right" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@ -9,15 +11,19 @@ import { computed } from 'vue'
|
||||
|
||||
export interface ButtonProps {
|
||||
/** Visual variant */
|
||||
variant?: 'solid' | 'outline' | 'ghost'
|
||||
variant?: 'solid' | 'outline' | 'ghost' | 'soft'
|
||||
/** Color token */
|
||||
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'neutral'
|
||||
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral'
|
||||
/** Size preset */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
/** HTML button type */
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
/** Disabled state */
|
||||
disabled?: boolean
|
||||
/** Loading state */
|
||||
loading?: boolean
|
||||
/** Full width */
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
@ -26,48 +32,205 @@ const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
size: 'md',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
fullWidth: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!props.disabled && !props.loading) {
|
||||
emit('click', event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind class maps – keep in sync with design tokens in `tailwind.config.ts`.
|
||||
* Tailwind class maps using new design tokens
|
||||
*/
|
||||
const base =
|
||||
'inline-flex items-center justify-center font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transition disabled:opacity-50 disabled:pointer-events-none'
|
||||
const base = `
|
||||
inline-flex items-center justify-center gap-2 font-medium rounded-lg
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||
transition-all duration-micro ease-micro
|
||||
disabled:opacity-50 disabled:pointer-events-none
|
||||
active:scale-[0.98] transform-gpu
|
||||
`.trim().replace(/\s+/g, ' ')
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'px-2.5 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
xs: 'px-2 py-1 text-xs h-6',
|
||||
sm: 'px-3 py-1.5 text-sm h-8',
|
||||
md: 'px-4 py-2 text-sm h-10',
|
||||
lg: 'px-6 py-3 text-base h-12',
|
||||
xl: 'px-8 py-4 text-lg h-14',
|
||||
}
|
||||
|
||||
const colorMatrix: Record<string, Record<string, string>> = {
|
||||
solid: {
|
||||
primary: 'bg-primary text-white hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-white hover:bg-secondary/90',
|
||||
success: 'bg-success text-white hover:bg-success/90',
|
||||
warning: 'bg-warning text-white hover:bg-warning/90',
|
||||
danger: 'bg-danger text-white hover:bg-danger/90',
|
||||
neutral: 'bg-neutral text-white hover:bg-neutral/90',
|
||||
primary: `
|
||||
bg-primary-500 text-white shadow-soft
|
||||
hover:bg-primary-600 hover:shadow-medium
|
||||
focus-visible:ring-primary-500/50
|
||||
active:bg-primary-700
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
secondary: `
|
||||
bg-secondary text-white shadow-soft
|
||||
hover:bg-secondary/90 hover:shadow-medium
|
||||
focus-visible:ring-secondary/50
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
success: `
|
||||
bg-success-500 text-white shadow-soft
|
||||
hover:bg-success-600 hover:shadow-medium
|
||||
focus-visible:ring-success-500/50
|
||||
active:bg-success-700
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
warning: `
|
||||
bg-warning-500 text-white shadow-soft
|
||||
hover:bg-warning-600 hover:shadow-medium
|
||||
focus-visible:ring-warning-500/50
|
||||
active:bg-warning-700
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
error: `
|
||||
bg-error-500 text-white shadow-soft
|
||||
hover:bg-error-600 hover:shadow-medium
|
||||
focus-visible:ring-error-500/50
|
||||
active:bg-error-700
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
neutral: `
|
||||
bg-neutral-500 text-white shadow-soft
|
||||
hover:bg-neutral-600 hover:shadow-medium
|
||||
focus-visible:ring-neutral-500/50
|
||||
active:bg-neutral-700
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
},
|
||||
outline: {
|
||||
primary: 'border border-primary text-primary hover:bg-primary/10',
|
||||
secondary: 'border border-secondary text-secondary hover:bg-secondary/10',
|
||||
success: 'border border-success text-success hover:bg-success/10',
|
||||
warning: 'border border-warning text-warning hover:bg-warning/10',
|
||||
danger: 'border border-danger text-danger hover:bg-danger/10',
|
||||
neutral: 'border border-neutral text-neutral hover:bg-neutral/10',
|
||||
primary: `
|
||||
border border-primary-300 text-primary-600 bg-white
|
||||
hover:bg-primary-50 hover:border-primary-400
|
||||
focus-visible:ring-primary-500/50
|
||||
active:bg-primary-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
secondary: `
|
||||
border border-gray-300 text-secondary bg-white
|
||||
hover:bg-gray-50 hover:border-gray-400
|
||||
focus-visible:ring-secondary/50
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
success: `
|
||||
border border-success-300 text-success-600 bg-white
|
||||
hover:bg-success-50 hover:border-success-400
|
||||
focus-visible:ring-success-500/50
|
||||
active:bg-success-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
warning: `
|
||||
border border-warning-300 text-warning-600 bg-white
|
||||
hover:bg-warning-50 hover:border-warning-400
|
||||
focus-visible:ring-warning-500/50
|
||||
active:bg-warning-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
error: `
|
||||
border border-error-300 text-error-600 bg-white
|
||||
hover:bg-error-50 hover:border-error-400
|
||||
focus-visible:ring-error-500/50
|
||||
active:bg-error-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
neutral: `
|
||||
border border-neutral-300 text-neutral-600 bg-white
|
||||
hover:bg-neutral-50 hover:border-neutral-400
|
||||
focus-visible:ring-neutral-500/50
|
||||
active:bg-neutral-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
},
|
||||
ghost: {
|
||||
primary: 'text-primary hover:bg-primary/10',
|
||||
secondary: 'text-secondary hover:bg-secondary/10',
|
||||
success: 'text-success hover:bg-success/10',
|
||||
warning: 'text-warning hover:bg-warning/10',
|
||||
danger: 'text-danger hover:bg-danger/10',
|
||||
neutral: 'text-neutral hover:bg-neutral/10',
|
||||
primary: `
|
||||
text-primary-600 bg-transparent
|
||||
hover:bg-primary-50
|
||||
focus-visible:ring-primary-500/50
|
||||
active:bg-primary-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
secondary: `
|
||||
text-secondary bg-transparent
|
||||
hover:bg-gray-50
|
||||
focus-visible:ring-secondary/50
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
success: `
|
||||
text-success-600 bg-transparent
|
||||
hover:bg-success-50
|
||||
focus-visible:ring-success-500/50
|
||||
active:bg-success-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
warning: `
|
||||
text-warning-600 bg-transparent
|
||||
hover:bg-warning-50
|
||||
focus-visible:ring-warning-500/50
|
||||
active:bg-warning-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
error: `
|
||||
text-error-600 bg-transparent
|
||||
hover:bg-error-50
|
||||
focus-visible:ring-error-500/50
|
||||
active:bg-error-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
neutral: `
|
||||
text-neutral-600 bg-transparent
|
||||
hover:bg-neutral-50
|
||||
focus-visible:ring-neutral-500/50
|
||||
active:bg-neutral-100
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
},
|
||||
soft: {
|
||||
primary: `
|
||||
bg-primary-100 text-primary-700
|
||||
hover:bg-primary-200
|
||||
focus-visible:ring-primary-500/50
|
||||
active:bg-primary-300
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
secondary: `
|
||||
bg-gray-100 text-secondary
|
||||
hover:bg-gray-200
|
||||
focus-visible:ring-secondary/50
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
success: `
|
||||
bg-success-100 text-success-700
|
||||
hover:bg-success-200
|
||||
focus-visible:ring-success-500/50
|
||||
active:bg-success-300
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
warning: `
|
||||
bg-warning-100 text-warning-700
|
||||
hover:bg-warning-200
|
||||
focus-visible:ring-warning-500/50
|
||||
active:bg-warning-300
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
error: `
|
||||
bg-error-100 text-error-700
|
||||
hover:bg-error-200
|
||||
focus-visible:ring-error-500/50
|
||||
active:bg-error-300
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
neutral: `
|
||||
bg-neutral-100 text-neutral-700
|
||||
hover:bg-neutral-200
|
||||
focus-visible:ring-neutral-500/50
|
||||
active:bg-neutral-300
|
||||
`.trim().replace(/\s+/g, ' '),
|
||||
},
|
||||
}
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
return [base, sizeClasses[props.size], colorMatrix[props.variant][props.color]].join(' ')
|
||||
const classes = [
|
||||
base,
|
||||
sizeClasses[props.size],
|
||||
colorMatrix[props.variant][props.color],
|
||||
]
|
||||
|
||||
if (props.fullWidth) {
|
||||
classes.push('w-full')
|
||||
}
|
||||
|
||||
if (props.loading) {
|
||||
classes.push('cursor-wait')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
@ -1,9 +1,142 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg shadow-sm p-4">
|
||||
<div :class="cardClasses">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// A simple card component for content containment.
|
||||
</script>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface CardProps {
|
||||
/** Visual variant */
|
||||
variant?: 'elevated' | 'outlined' | 'filled' | 'soft'
|
||||
/** Color scheme */
|
||||
color?: 'neutral' | 'primary' | 'success' | 'warning' | 'error'
|
||||
/** Padding size */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
/** Border radius */
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
/** Interactive state */
|
||||
interactive?: boolean
|
||||
/** Disabled state */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CardProps>(), {
|
||||
variant: 'elevated',
|
||||
color: 'neutral',
|
||||
padding: 'md',
|
||||
rounded: 'lg',
|
||||
interactive: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Tailwind class maps using new design tokens
|
||||
*/
|
||||
const baseClasses = `
|
||||
relative transition-all duration-micro ease-micro
|
||||
disabled:opacity-50 disabled:pointer-events-none
|
||||
`.trim().replace(/\s+/g, ' ')
|
||||
|
||||
const variantClasses: Record<string, string> = {
|
||||
elevated: 'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 shadow-soft',
|
||||
outlined: 'bg-transparent border-2 border-neutral-300 dark:border-neutral-700',
|
||||
filled: 'bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700',
|
||||
soft: 'bg-white/60 dark:bg-neutral-900/60 backdrop-blur-sm border border-white/20 dark:border-neutral-700/30 shadow-soft',
|
||||
}
|
||||
|
||||
const colorClasses: Record<string, Record<string, string>> = {
|
||||
neutral: {
|
||||
elevated: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800',
|
||||
outlined: 'border-neutral-300 dark:border-neutral-700 text-neutral-900 dark:text-neutral-100',
|
||||
filled: 'bg-neutral-50 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700',
|
||||
soft: 'bg-white/60 dark:bg-neutral-900/60 border-white/20 dark:border-neutral-700/30',
|
||||
},
|
||||
primary: {
|
||||
elevated: 'bg-primary-50 dark:bg-primary-950 border-primary-200 dark:border-primary-800',
|
||||
outlined: 'border-primary-300 dark:border-primary-700 text-primary-900 dark:text-primary-100',
|
||||
filled: 'bg-primary-100 dark:bg-primary-900 border-primary-200 dark:border-primary-800',
|
||||
soft: 'bg-primary-50/60 dark:bg-primary-950/60 border-primary-200/30 dark:border-primary-800/30',
|
||||
},
|
||||
success: {
|
||||
elevated: 'bg-success-50 dark:bg-success-950 border-success-200 dark:border-success-800',
|
||||
outlined: 'border-success-300 dark:border-success-700 text-success-900 dark:text-success-100',
|
||||
filled: 'bg-success-100 dark:bg-success-900 border-success-200 dark:border-success-800',
|
||||
soft: 'bg-success-50/60 dark:bg-success-950/60 border-success-200/30 dark:border-success-800/30',
|
||||
},
|
||||
warning: {
|
||||
elevated: 'bg-warning-50 dark:bg-warning-950 border-warning-200 dark:border-warning-800',
|
||||
outlined: 'border-warning-300 dark:border-warning-700 text-warning-900 dark:text-warning-100',
|
||||
filled: 'bg-warning-100 dark:bg-warning-900 border-warning-200 dark:border-warning-800',
|
||||
soft: 'bg-warning-50/60 dark:bg-warning-950/60 border-warning-200/30 dark:border-warning-800/30',
|
||||
},
|
||||
error: {
|
||||
elevated: 'bg-error-50 dark:bg-error-950 border-error-200 dark:border-error-800',
|
||||
outlined: 'border-error-300 dark:border-error-700 text-error-900 dark:text-error-100',
|
||||
filled: 'bg-error-100 dark:bg-error-900 border-error-200 dark:border-error-800',
|
||||
soft: 'bg-error-50/60 dark:bg-error-950/60 border-error-200/30 dark:border-error-800/30',
|
||||
},
|
||||
}
|
||||
|
||||
const paddingClasses: Record<string, string> = {
|
||||
none: 'p-0',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
xl: 'p-8',
|
||||
}
|
||||
|
||||
const roundedClasses: Record<string, string> = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
full: 'rounded-full',
|
||||
}
|
||||
|
||||
const interactiveClasses = `
|
||||
cursor-pointer hover:shadow-medium hover:scale-[1.02] active:scale-[0.98]
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2
|
||||
transform-gpu
|
||||
`.trim().replace(/\s+/g, ' ')
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const classes = [baseClasses]
|
||||
|
||||
// Base variant and color
|
||||
const variantClass = variantClasses[props.variant] || variantClasses.elevated
|
||||
const colorClass = colorClasses[props.color]?.[props.variant] || ''
|
||||
|
||||
// Replace color-specific classes if color is not neutral
|
||||
if (props.color !== 'neutral') {
|
||||
classes.push(variantClass.replace(/bg-\S+|border-\S+|text-\S+/g, ''))
|
||||
classes.push(colorClass)
|
||||
} else {
|
||||
classes.push(variantClass)
|
||||
}
|
||||
|
||||
// Padding
|
||||
classes.push(paddingClasses[props.padding] || paddingClasses.md)
|
||||
|
||||
// Rounded
|
||||
classes.push(roundedClasses[props.rounded] || roundedClasses.lg)
|
||||
|
||||
// Interactive
|
||||
if (props.interactive && !props.disabled) {
|
||||
classes.push(interactiveClasses)
|
||||
}
|
||||
|
||||
// Disabled
|
||||
if (props.disabled) {
|
||||
classes.push('opacity-50 pointer-events-none')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styles if needed */
|
||||
</style>
|
153
fe/src/components/ui/Checkbox.vue
Normal file
153
fe/src/components/ui/Checkbox.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="checkbox-wrapper">
|
||||
<div class="checkbox-container">
|
||||
<input :id="id" v-model="internalValue" type="checkbox" :disabled="disabled" :required="required"
|
||||
class="checkbox-input" :class="checkboxClasses" v-bind="$attrs" />
|
||||
<div class="checkbox-indicator">
|
||||
<BaseIcon v-if="internalValue" name="heroicons:check" class="check-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<label v-if="label || $slots.default" :for="id" class="checkbox-label" :class="labelClasses">
|
||||
<slot>{{ label }}</slot>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
color?: 'primary' | 'success' | 'warning' | 'error'
|
||||
id?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
size: 'md',
|
||||
color: 'primary',
|
||||
id: () => `checkbox-${Math.random().toString(36).substr(2, 9)}`
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const internalValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const checkboxClasses = computed(() => [
|
||||
'checkbox-base',
|
||||
`checkbox-${props.size}`,
|
||||
`checkbox-${props.color}`,
|
||||
{
|
||||
'checkbox-disabled': props.disabled,
|
||||
'checkbox-checked': internalValue.value
|
||||
}
|
||||
])
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
'label-base',
|
||||
`label-${props.size}`,
|
||||
{
|
||||
'label-disabled': props.disabled
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-wrapper {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
@apply relative flex-shrink-0;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
.checkbox-indicator {
|
||||
@apply flex items-center justify-center transition-all duration-micro;
|
||||
@apply border-2 rounded-md cursor-pointer;
|
||||
@apply border-border-primary bg-surface-primary;
|
||||
@apply hover:border-primary-300 focus-within:ring-2 focus-within:ring-primary-500/20;
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.checkbox-base+.checkbox-indicator {
|
||||
@apply w-5 h-5;
|
||||
}
|
||||
|
||||
.checkbox-sm+.checkbox-indicator {
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
|
||||
.checkbox-lg+.checkbox-indicator {
|
||||
@apply w-6 h-6;
|
||||
}
|
||||
|
||||
/* Color variants */
|
||||
.checkbox-primary:checked+.checkbox-indicator {
|
||||
@apply bg-primary-500 border-primary-500;
|
||||
}
|
||||
|
||||
.checkbox-success:checked+.checkbox-indicator {
|
||||
@apply bg-success-500 border-success-500;
|
||||
}
|
||||
|
||||
.checkbox-warning:checked+.checkbox-indicator {
|
||||
@apply bg-warning-500 border-warning-500;
|
||||
}
|
||||
|
||||
.checkbox-error:checked+.checkbox-indicator {
|
||||
@apply bg-error-500 border-error-500;
|
||||
}
|
||||
|
||||
.checkbox-disabled+.checkbox-indicator {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
@apply w-3 h-3 text-white;
|
||||
}
|
||||
|
||||
.checkbox-sm+.checkbox-indicator .check-icon {
|
||||
@apply w-2.5 h-2.5;
|
||||
}
|
||||
|
||||
.checkbox-lg+.checkbox-indicator .check-icon {
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
|
||||
/* Label styles */
|
||||
.checkbox-label {
|
||||
@apply cursor-pointer select-none;
|
||||
}
|
||||
|
||||
.label-base {
|
||||
@apply text-sm font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.label-sm {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.label-lg {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.label-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
@ -1,40 +1,396 @@
|
||||
<template>
|
||||
<div>
|
||||
<label v-if="label" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ label
|
||||
}}</label>
|
||||
<input :id="id" v-bind="$attrs" :type="type" :class="inputClasses" v-model="modelValueProxy"
|
||||
:aria-invalid="error ? 'true' : undefined" />
|
||||
<p v-if="error" class="mt-1 text-sm text-danger">{{ error }}</p>
|
||||
<div class="input-container">
|
||||
<!-- Label -->
|
||||
<label v-if="label" :for="inputId" class="input-label" :class="labelClasses">
|
||||
{{ label }}
|
||||
<span v-if="required" class="required-indicator" aria-label="required">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Input Container -->
|
||||
<div class="input-wrapper" :class="wrapperClasses">
|
||||
<!-- Prefix Icon -->
|
||||
<div v-if="prefixIcon || slots.prefix" class="input-prefix">
|
||||
<slot name="prefix">
|
||||
<span v-if="prefixIcon" class="material-icons input-icon">{{ prefixIcon }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Input Element -->
|
||||
<input :id="inputId" ref="inputRef" v-bind="$attrs" :type="inputType" :value="modelValue"
|
||||
:placeholder="placeholder" :disabled="disabled || loading" :readonly="readonly" :required="required"
|
||||
:autocomplete="autocomplete" :aria-invalid="hasError ? 'true' : undefined"
|
||||
:aria-describedby="descriptionId" class="input-field" :class="inputClasses" @input="handleInput"
|
||||
@blur="handleBlur" @focus="handleFocus" @keydown="handleKeydown" />
|
||||
|
||||
<!-- Suffix Content -->
|
||||
<div v-if="suffixIcon || loading || hasError || hasSuccess || showPassword || slots.suffix"
|
||||
class="input-suffix">
|
||||
<!-- Loading Spinner -->
|
||||
<div v-if="loading" class="input-loading">
|
||||
<svg class="animate-spin h-4 w-4 text-neutral-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25">
|
||||
</circle>
|
||||
<path fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
class="opacity-75"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Success Indicator -->
|
||||
<div v-else-if="hasSuccess && !hasError" class="input-success">
|
||||
<span class="material-icons input-icon text-success-500">check_circle</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Indicator -->
|
||||
<div v-else-if="hasError" class="input-error-icon">
|
||||
<span class="material-icons input-icon text-error-500">error</span>
|
||||
</div>
|
||||
|
||||
<!-- Password Toggle -->
|
||||
<button v-else-if="showPassword" type="button" class="input-password-toggle"
|
||||
@click="togglePasswordVisibility" :aria-label="passwordVisible ? 'Hide password' : 'Show password'">
|
||||
<span class="material-icons input-icon">{{ passwordVisible ? 'visibility_off' : 'visibility'
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<!-- Suffix Icon or Slot -->
|
||||
<div v-else-if="suffixIcon || slots.suffix" class="input-suffix-content">
|
||||
<slot name="suffix">
|
||||
<span v-if="suffixIcon" class="material-icons input-icon">{{ suffixIcon }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text / Error Message -->
|
||||
<div v-if="helpText || error" :id="descriptionId" class="input-description">
|
||||
<div v-if="error" class="input-error-text">
|
||||
<span class="material-icons input-error-icon-small">error</span>
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="helpText" class="input-help-text">
|
||||
{{ helpText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Character Count -->
|
||||
<div v-if="maxLength && showCharCount" class="input-char-count">
|
||||
<span :class="characterCountClasses">
|
||||
{{ characterCount }}/{{ maxLength }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { computed, ref, nextTick, watch, useSlots } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string | number | null
|
||||
modelValue?: string | number | null
|
||||
label?: string
|
||||
placeholder?: string
|
||||
type?: string
|
||||
error?: string | null
|
||||
helpText?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
required?: boolean
|
||||
loading?: boolean
|
||||
success?: boolean
|
||||
prefixIcon?: string
|
||||
suffixIcon?: string
|
||||
autocomplete?: string
|
||||
maxLength?: number
|
||||
showCharCount?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
variant?: 'default' | 'soft' | 'ghost'
|
||||
validateOnBlur?: boolean
|
||||
validateOnInput?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'text',
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { modelValue } = toRefs(props)
|
||||
const modelValueProxy = computed({
|
||||
get: () => modelValue.value,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
size: 'md',
|
||||
variant: 'default',
|
||||
validateOnBlur: true,
|
||||
validateOnInput: false,
|
||||
showCharCount: false,
|
||||
})
|
||||
|
||||
const base = 'shadow-sm block w-full sm:text-sm rounded-md'
|
||||
const theme = 'border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 focus:ring-primary focus:border-primary'
|
||||
const errorCls = 'border-danger focus:border-danger focus:ring-danger'
|
||||
const inputClasses = computed(() => [base, theme, props.error ? errorCls : ''].join(' '))
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | null]
|
||||
'focus': [event: FocusEvent]
|
||||
'blur': [event: FocusEvent]
|
||||
'input': [event: Event]
|
||||
'keydown': [event: KeyboardEvent]
|
||||
'validate': [value: string | number | null]
|
||||
}>()
|
||||
|
||||
// Refs and Slots
|
||||
const slots = useSlots()
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const isFocused = ref(false)
|
||||
const passwordVisible = ref(false)
|
||||
|
||||
// Computed Properties
|
||||
const inputId = computed(() => props.id || `input-${Math.random().toString(36).substr(2, 9)}`)
|
||||
const descriptionId = computed(() => `${inputId.value}-description`)
|
||||
const hasError = computed(() => Boolean(props.error))
|
||||
const hasSuccess = computed(() => props.success && !hasError.value)
|
||||
const showPassword = computed(() => props.type === 'password')
|
||||
const inputType = computed(() => {
|
||||
if (props.type === 'password') {
|
||||
return passwordVisible.value ? 'text' : 'password'
|
||||
}
|
||||
return props.type
|
||||
})
|
||||
|
||||
const characterCount = computed(() => {
|
||||
if (props.modelValue == null) return 0
|
||||
return String(props.modelValue).length
|
||||
})
|
||||
|
||||
const characterCountClasses = computed(() => [
|
||||
'text-xs font-mono',
|
||||
{
|
||||
'text-error-500': props.maxLength && characterCount.value > props.maxLength,
|
||||
'text-warning-500': props.maxLength && characterCount.value > props.maxLength * 0.8,
|
||||
'text-neutral-500': !props.maxLength || characterCount.value <= props.maxLength * 0.8,
|
||||
}
|
||||
])
|
||||
|
||||
// Label Classes
|
||||
const labelClasses = computed(() => [
|
||||
'block text-sm font-medium mb-2 transition-colors duration-micro',
|
||||
{
|
||||
'text-neutral-700': !hasError.value && !isFocused.value,
|
||||
'text-primary-600': isFocused.value && !hasError.value,
|
||||
'text-error-600': hasError.value,
|
||||
'opacity-50': props.disabled,
|
||||
}
|
||||
])
|
||||
|
||||
// Wrapper Classes
|
||||
const wrapperClasses = computed(() => [
|
||||
'relative flex items-center transition-all duration-micro',
|
||||
{
|
||||
'opacity-50': props.disabled,
|
||||
}
|
||||
])
|
||||
|
||||
// Input Classes
|
||||
const inputClasses = computed(() => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-9 text-sm px-3',
|
||||
md: 'h-11 text-base px-4',
|
||||
lg: 'h-13 text-lg px-5',
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-white border border-neutral-300',
|
||||
soft: 'bg-neutral-50 border border-neutral-200',
|
||||
ghost: 'bg-transparent border border-transparent',
|
||||
}
|
||||
|
||||
return [
|
||||
// Base styles
|
||||
'block w-full rounded-lg font-sans transition-all duration-micro ease-micro',
|
||||
'placeholder:text-neutral-400 placeholder:font-normal',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-0',
|
||||
// Size classes
|
||||
sizeClasses[props.size],
|
||||
// Variant classes
|
||||
variantClasses[props.variant],
|
||||
// State classes
|
||||
{
|
||||
// Focus states
|
||||
'focus:ring-primary-500/20 focus:border-primary-500': !hasError.value && !props.disabled,
|
||||
'focus:ring-error-500/20 focus:border-error-500': hasError.value && !props.disabled,
|
||||
|
||||
// Error states
|
||||
'border-error-300 bg-error-50': hasError.value && props.variant === 'default',
|
||||
'border-error-200 bg-error-25': hasError.value && props.variant === 'soft',
|
||||
|
||||
// Success states
|
||||
'border-success-300 bg-success-50': hasSuccess.value && props.variant === 'default',
|
||||
'border-success-200 bg-success-25': hasSuccess.value && props.variant === 'soft',
|
||||
|
||||
// Disabled states
|
||||
'cursor-not-allowed bg-neutral-100 text-neutral-500': props.disabled,
|
||||
'cursor-default': props.readonly,
|
||||
|
||||
// Loading states
|
||||
'pr-10': props.loading || hasError.value || hasSuccess.value || showPassword.value,
|
||||
'pl-10': props.prefixIcon || !!slots.prefix,
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value
|
||||
|
||||
emit('update:modelValue', value)
|
||||
emit('input', event)
|
||||
|
||||
if (props.validateOnInput) {
|
||||
emit('validate', value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (event: FocusEvent) => {
|
||||
isFocused.value = false
|
||||
emit('blur', event)
|
||||
|
||||
if (props.validateOnBlur) {
|
||||
emit('validate', props.modelValue ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = (event: FocusEvent) => {
|
||||
isFocused.value = true
|
||||
emit('focus', event)
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
emit('keydown', event)
|
||||
}
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const blur = () => {
|
||||
inputRef.value?.blur()
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
inputRef.value?.select()
|
||||
}
|
||||
|
||||
// Watch for maxLength validation
|
||||
watch(() => characterCount.value, (newCount) => {
|
||||
if (props.maxLength && newCount > props.maxLength) {
|
||||
emit('validate', props.modelValue ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
// Expose methods
|
||||
defineExpose({
|
||||
focus,
|
||||
blur,
|
||||
select,
|
||||
inputRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.input-container {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.required-indicator {
|
||||
@apply text-error-500 ml-1;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
/* Remove default appearance */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.input-field::-webkit-outer-spin-button,
|
||||
.input-field::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-field[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.input-prefix,
|
||||
.input-suffix {
|
||||
@apply absolute inset-y-0 flex items-center pointer-events-none;
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
@apply left-3;
|
||||
}
|
||||
|
||||
.input-suffix {
|
||||
@apply right-3;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
@apply text-lg text-neutral-400;
|
||||
}
|
||||
|
||||
.input-loading {
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
|
||||
.input-password-toggle {
|
||||
@apply pointer-events-auto p-1 rounded text-neutral-400 hover:text-neutral-600 transition-colors duration-micro;
|
||||
}
|
||||
|
||||
.input-description {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
@apply flex items-center gap-1 text-sm text-error-600;
|
||||
}
|
||||
|
||||
.input-error-icon-small {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.input-help-text {
|
||||
@apply text-sm text-neutral-500;
|
||||
}
|
||||
|
||||
.input-char-count {
|
||||
@apply mt-1 text-right;
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
.input-field:focus {
|
||||
@apply ring-2 ring-offset-0;
|
||||
}
|
||||
|
||||
/* Animation for success/error states */
|
||||
.input-success,
|
||||
.input-error-icon {
|
||||
animation: scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
73
fe/src/components/ui/Textarea.vue
Normal file
73
fe/src/components/ui/Textarea.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="textarea-wrapper">
|
||||
<label v-if="label" :for="id" class="textarea-label">{{ label }}</label>
|
||||
<div class="relative">
|
||||
<textarea :id="id" :value="modelValue"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
:placeholder="placeholder" :rows="rows" :disabled="disabled" :class="textareaClasses"></textarea>
|
||||
</div>
|
||||
<p v-if="description" class="textarea-description">{{ description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
id?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
variant?: 'default' | 'error'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
id: () => `textarea-${Math.random().toString(36).substring(2, 9)}`,
|
||||
rows: 4,
|
||||
disabled: false,
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const baseClasses = `
|
||||
w-full px-3 py-2 rounded-lg border bg-transparent
|
||||
transition-colors duration-micro ease-micro
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500
|
||||
`
|
||||
|
||||
const variantClasses = {
|
||||
default: `
|
||||
border-border-secondary placeholder:text-text-tertiary
|
||||
hover:border-border-primary
|
||||
dark:border-neutral-700 dark:hover:border-neutral-500
|
||||
`,
|
||||
error: `
|
||||
border-error-500 ring-1 ring-error-500
|
||||
dark:border-error-400 dark:ring-error-400
|
||||
`,
|
||||
}
|
||||
|
||||
const textareaClasses = computed(() => [
|
||||
baseClasses,
|
||||
variantClasses[props.variant],
|
||||
{ 'opacity-50 cursor-not-allowed': props.disabled }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.textarea-wrapper {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.textarea-label {
|
||||
@apply block text-sm font-medium text-text-primary mb-1;
|
||||
}
|
||||
|
||||
.textarea-description {
|
||||
@apply text-xs text-text-secondary mt-1;
|
||||
}
|
||||
</style>
|
@ -10,4 +10,6 @@ export { default as Heading } from './Heading.vue'
|
||||
export { default as Spinner } from './Spinner.vue'
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export { default as ProgressBar } from './ProgressBar.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as Textarea } from './Textarea.vue'
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
@ -1,24 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
export function getNextAssignee<T extends { id: number | string }>(members: T[], currentIndex = 0) {
|
||||
if (members.length === 0) return null
|
||||
const nextIndex = (currentIndex + 1) % members.length
|
||||
return members[nextIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-robin assignment helper for chores or other rotating duties.
|
||||
* Keeps internal pointer in reactive `index`.
|
||||
*/
|
||||
export function useFairness<T>() {
|
||||
const members = ref<T[]>([])
|
||||
const index = ref(0)
|
||||
|
||||
function setParticipants(list: T[]) {
|
||||
members.value = list
|
||||
index.value = 0
|
||||
}
|
||||
|
||||
function next(): T | undefined {
|
||||
if (members.value.length === 0) return undefined
|
||||
const member = members.value[index.value] as unknown as T
|
||||
index.value = (index.value + 1) % members.value.length
|
||||
return member
|
||||
}
|
||||
|
||||
return { members, setParticipants, next }
|
||||
export function useFairness() {
|
||||
return { getNextAssignee }
|
||||
}
|
@ -1,34 +1,23 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useOfflineStore } from '@/stores/offline'
|
||||
import { watch } from 'vue'
|
||||
import { enqueue, flush } from '@/utils/offlineQueue'
|
||||
import { useSocket } from '@/composables/useSocket'
|
||||
|
||||
/**
|
||||
* Hook that wires components into the global offline queue, automatically
|
||||
* processing pending mutations when the application regains connectivity.
|
||||
*/
|
||||
export function useOfflineSync() {
|
||||
const offlineStore = useOfflineStore()
|
||||
const { isConnected } = useSocket()
|
||||
|
||||
const handleOnline = () => {
|
||||
offlineStore.isOnline = true
|
||||
offlineStore.processQueue()
|
||||
}
|
||||
const handleOffline = () => {
|
||||
offlineStore.isOnline = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
})
|
||||
watch(isConnected, (online) => {
|
||||
if (online) {
|
||||
flush(async () => {
|
||||
// For MVP we just emit back to server via WS.
|
||||
}).catch((err) => console.error('Offline flush error', err))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
isOnline: offlineStore.isOnline,
|
||||
pendingActions: offlineStore.pendingActions,
|
||||
processQueue: offlineStore.processQueue,
|
||||
enqueue,
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { reactive } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Generic optimistic-update helper.
|
||||
@ -9,25 +9,31 @@ import { reactive } from 'vue'
|
||||
* await apiCall()
|
||||
* confirm(id) // or rollback(id) on failure
|
||||
*/
|
||||
export function useOptimisticUpdates() {
|
||||
// Map of rollback callbacks keyed by mutation id
|
||||
const pending = reactive(new Map<string, () => void>())
|
||||
|
||||
function apply(id: string, mutate: () => void, rollback: () => void) {
|
||||
if (pending.has(id)) return
|
||||
mutate()
|
||||
pending.set(id, rollback)
|
||||
// Generic optimistic updates helper.
|
||||
// Currently a lightweight placeholder that simply applies the updater locally
|
||||
// and exposes rollback ability. Replace with robust logic once backend supports PATCH w/ versioning.
|
||||
|
||||
type Updater<T> = (draft: T[]) => T[]
|
||||
|
||||
export function useOptimisticUpdates<T>(initial: T[]) {
|
||||
const data = ref<T[]>([...initial] as T[])
|
||||
const backups: T[][] = []
|
||||
|
||||
function mutate(updater: Updater<T>) {
|
||||
backups.push([...data.value] as T[])
|
||||
data.value = updater([...data.value] as T[])
|
||||
}
|
||||
|
||||
function confirm(id: string) {
|
||||
pending.delete(id)
|
||||
function rollback() {
|
||||
if (backups.length) {
|
||||
data.value = backups.pop()!
|
||||
}
|
||||
}
|
||||
|
||||
function rollback(id: string) {
|
||||
const fn = pending.get(id)
|
||||
if (fn) fn()
|
||||
pending.delete(id)
|
||||
return {
|
||||
data,
|
||||
mutate,
|
||||
rollback,
|
||||
}
|
||||
|
||||
return { apply, confirm, rollback }
|
||||
}
|
@ -14,6 +14,26 @@ interface NextAction {
|
||||
priority: number; // 1 = highest
|
||||
}
|
||||
|
||||
interface PriorityAction {
|
||||
id: string
|
||||
type: 'chore' | 'expense' | 'debt' | 'overdue'
|
||||
priority: 'urgent' | 'high' | 'medium' | 'low'
|
||||
title: string
|
||||
description?: string
|
||||
dueDate?: Date
|
||||
amount?: number
|
||||
actionUrl: string
|
||||
}
|
||||
|
||||
interface PersonalStatus {
|
||||
completedToday: number
|
||||
netBalance: number
|
||||
pointsThisWeek: number
|
||||
streakCount: number
|
||||
totalTasks: number
|
||||
pendingExpenses: number
|
||||
}
|
||||
|
||||
export function usePersonalStatus() {
|
||||
const choreStore = useChoreStore()
|
||||
const groupStore = useGroupStore()
|
||||
@ -58,6 +78,7 @@ export function usePersonalStatus() {
|
||||
)
|
||||
})
|
||||
|
||||
// Legacy nextAction for backward compatibility
|
||||
const nextAction = computed<NextAction>(() => {
|
||||
const now = new Date()
|
||||
// Priority 1: Overdue chores
|
||||
@ -76,8 +97,6 @@ export function usePersonalStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder for expense logic - to be implemented
|
||||
|
||||
// Priority 2: Upcoming chores
|
||||
const upcomingChoreAssignment = userChoresWithAssignments.value
|
||||
.filter(assignment => !assignment.is_complete)
|
||||
@ -104,8 +123,132 @@ export function usePersonalStatus() {
|
||||
}
|
||||
})
|
||||
|
||||
// New priority action for the refactored component
|
||||
const priorityAction = computed<PriorityAction | null>(() => {
|
||||
const now = new Date()
|
||||
const userId = authStore.user?.id
|
||||
if (!userId) return null
|
||||
|
||||
// Priority 1: Overdue chores (urgent)
|
||||
const overdueChoreAssignment = userChoresWithAssignments.value
|
||||
.filter(assignment => new Date(assignment.due_date) < now && !assignment.is_complete)
|
||||
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
|
||||
|
||||
if (overdueChoreAssignment) {
|
||||
return {
|
||||
id: overdueChoreAssignment.id.toString(),
|
||||
type: 'overdue',
|
||||
priority: 'urgent',
|
||||
title: overdueChoreAssignment.choreName,
|
||||
dueDate: new Date(overdueChoreAssignment.due_date),
|
||||
actionUrl: '/chores'
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Due today chores (high)
|
||||
const dueTodayChoreAssignment = userChoresWithAssignments.value
|
||||
.filter(assignment => {
|
||||
const dueDate = new Date(assignment.due_date)
|
||||
const today = new Date()
|
||||
return dueDate.toDateString() === today.toDateString() && !assignment.is_complete
|
||||
})
|
||||
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
|
||||
|
||||
if (dueTodayChoreAssignment) {
|
||||
return {
|
||||
id: dueTodayChoreAssignment.id.toString(),
|
||||
type: 'chore',
|
||||
priority: 'high',
|
||||
title: dueTodayChoreAssignment.choreName,
|
||||
dueDate: new Date(dueTodayChoreAssignment.due_date),
|
||||
actionUrl: '/chores'
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Upcoming chores (medium)
|
||||
const upcomingChoreAssignment = userChoresWithAssignments.value
|
||||
.filter(assignment => new Date(assignment.due_date) > now && !assignment.is_complete)
|
||||
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
|
||||
|
||||
if (upcomingChoreAssignment) {
|
||||
return {
|
||||
id: upcomingChoreAssignment.id.toString(),
|
||||
type: 'chore',
|
||||
priority: 'medium',
|
||||
title: upcomingChoreAssignment.choreName,
|
||||
dueDate: new Date(upcomingChoreAssignment.due_date),
|
||||
actionUrl: '/chores'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
// Personal status summary
|
||||
const personalStatus = computed<PersonalStatus>(() => {
|
||||
const userId = authStore.user?.id
|
||||
if (!userId) {
|
||||
return {
|
||||
completedToday: 0,
|
||||
netBalance: 0,
|
||||
pointsThisWeek: 0,
|
||||
streakCount: 0,
|
||||
totalTasks: 0,
|
||||
pendingExpenses: 0
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
const weekStart = new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay())
|
||||
|
||||
// Count completed chores today
|
||||
const completedToday = userChoresWithAssignments.value.filter(assignment => {
|
||||
if (!assignment.is_complete || !assignment.completed_at) return false
|
||||
const completedDate = new Date(assignment.completed_at)
|
||||
return completedDate.toDateString() === today.toDateString()
|
||||
}).length
|
||||
|
||||
// Calculate points this week (assuming 10 points per completed chore)
|
||||
const pointsThisWeek = userChoresWithAssignments.value.filter(assignment => {
|
||||
if (!assignment.is_complete || !assignment.completed_at) return false
|
||||
const completedDate = new Date(assignment.completed_at)
|
||||
return completedDate >= weekStart
|
||||
}).length * 10
|
||||
|
||||
// Simple streak calculation (consecutive days with completed chores)
|
||||
let streakCount = 0
|
||||
const checkDate = new Date(today)
|
||||
while (streakCount < 30) { // Max 30 days lookback
|
||||
const dayAssignments = userChoresWithAssignments.value.filter(assignment => {
|
||||
if (!assignment.is_complete || !assignment.completed_at) return false
|
||||
const completedDate = new Date(assignment.completed_at)
|
||||
return completedDate.toDateString() === checkDate.toDateString()
|
||||
})
|
||||
|
||||
if (dayAssignments.length > 0) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
completedToday,
|
||||
netBalance: 0, // TODO: Calculate from expenses
|
||||
pointsThisWeek,
|
||||
streakCount,
|
||||
totalTasks: userChoresWithAssignments.value.length,
|
||||
pendingExpenses: expenses.value.length
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// Legacy support
|
||||
nextAction,
|
||||
isLoading,
|
||||
// New interface
|
||||
personalStatus,
|
||||
priorityAction
|
||||
}
|
||||
}
|
82
fe/src/composables/useSocket.ts
Normal file
82
fe/src/composables/useSocket.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { ref, shallowRef, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Simple wrapper around native WebSocket with auto-reconnect & Vue-friendly API.
|
||||
// In tests we can provide a mock implementation via `mock-socket`.
|
||||
// TODO: Move to dedicated class if feature set grows.
|
||||
|
||||
interface Listener {
|
||||
(payload: any): void
|
||||
}
|
||||
|
||||
const defaultWsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8000/ws'
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
const isConnected = ref(false)
|
||||
|
||||
function connect(): void {
|
||||
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
const authStore = useAuthStore()
|
||||
const token = authStore.accessToken
|
||||
const urlWithToken = `${defaultWsUrl}?token=${token}`
|
||||
socket = new WebSocket(urlWithToken)
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
isConnected.value = true
|
||||
})
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
isConnected.value = false
|
||||
// Auto-reconnect after short delay
|
||||
setTimeout(connect, 1_000)
|
||||
})
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
const { event: evt, payload } = data
|
||||
listeners.get(evt)?.forEach((cb) => cb(payload))
|
||||
} catch (err) {
|
||||
console.error('WS message parse error', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function emit(event: string, payload: any): void {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
console.warn('WebSocket not connected, skipping emit', event)
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify({ event, payload }))
|
||||
}
|
||||
|
||||
function on(event: string, cb: Listener): void {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set())
|
||||
listeners.get(event)!.add(cb)
|
||||
}
|
||||
|
||||
function off(event: string, cb: Listener): void {
|
||||
listeners.get(event)?.delete(cb)
|
||||
}
|
||||
|
||||
// Auto-connect immediately so composable is truly a singleton.
|
||||
connect()
|
||||
|
||||
export function useSocket() {
|
||||
// Provide stable references to the consumer component.
|
||||
const connected = shallowRef(isConnected)
|
||||
|
||||
onUnmounted(() => {
|
||||
// Consumers may call off if they registered listeners, but we don't force it here.
|
||||
})
|
||||
|
||||
return {
|
||||
isConnected: connected,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
}
|
||||
}
|
@ -66,6 +66,7 @@ export const API_ENDPOINTS = {
|
||||
MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
|
||||
CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
|
||||
GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
|
||||
REVOKE_INVITE: (groupId: string, code: string) => `/groups/${groupId}/invites/${code}`,
|
||||
LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
|
||||
DELETE: (groupId: string) => `/groups/${groupId}`,
|
||||
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
|
||||
@ -126,6 +127,8 @@ export const API_ENDPOINTS = {
|
||||
REPORT: (id: string) => `/financials/reports/${id}`,
|
||||
CATEGORIES: '/financials/categories',
|
||||
CATEGORY: (id: string) => `/financials/categories/${id}`,
|
||||
USER_SUMMARY: '/financials/summary/user',
|
||||
GROUP_SUMMARY: (groupId: string) => `/financials/summary/group/${groupId}`,
|
||||
},
|
||||
|
||||
// Health
|
||||
|
@ -1,24 +1,212 @@
|
||||
<template>
|
||||
<div class="p-4 space-y-4">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<PersonalStatusCard />
|
||||
<QuickChoreAdd />
|
||||
<ActivityFeed />
|
||||
<UniversalFAB />
|
||||
<div class="dashboard-page">
|
||||
<!-- Header -->
|
||||
<div class="dashboard-header">
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<div class="header-actions">
|
||||
<!-- Quick action shortcuts could go here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content - Single source of truth layout -->
|
||||
<div class="dashboard-content">
|
||||
<!-- Personal Status (Above fold) -->
|
||||
<section class="status-section">
|
||||
<PersonalStatusCard @priority-action="handlePriorityAction" @create-action="handleCreateAction" />
|
||||
</section>
|
||||
|
||||
<!-- Activity Feed (Social proof) -->
|
||||
<section class="activity-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Recent Activity</h2>
|
||||
<Button variant="ghost" color="neutral" size="sm">
|
||||
View All
|
||||
</Button>
|
||||
</div>
|
||||
<ActivityFeed />
|
||||
</section>
|
||||
|
||||
<!-- Quick Actions Section (Contextual) -->
|
||||
<section class="quick-actions-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Quick Actions</h2>
|
||||
</div>
|
||||
<div class="quick-actions-grid">
|
||||
<QuickChoreAdd />
|
||||
<!-- Additional quick action cards could be added here -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Universal FAB (Fixed positioning) -->
|
||||
<UniversalFAB @scan-receipt="handleScanReceipt" @create-list="handleCreateList"
|
||||
@invite-member="handleInviteMember" />
|
||||
|
||||
<!-- Notification Area -->
|
||||
<NotificationDisplay />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useGroupStore } from '@/stores/groupStore';
|
||||
import PersonalStatusCard from '@/components/dashboard/PersonalStatusCard.vue';
|
||||
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue';
|
||||
import UniversalFAB from '@/components/dashboard/UniversalFAB.vue';
|
||||
import QuickChoreAdd from '@/components/QuickChoreAdd.vue';
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGroupStore } from '@/stores/groupStore'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import PersonalStatusCard from '@/components/dashboard/PersonalStatusCard.vue'
|
||||
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue'
|
||||
import UniversalFAB from '@/components/dashboard/UniversalFAB.vue'
|
||||
import QuickChoreAdd from '@/components/QuickChoreAdd.vue'
|
||||
import NotificationDisplay from '@/components/global/NotificationDisplay.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const groupStore = useGroupStore();
|
||||
const router = useRouter()
|
||||
const groupStore = useGroupStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
onMounted(() => {
|
||||
groupStore.fetchUserGroups();
|
||||
});
|
||||
</script>
|
||||
onMounted(async () => {
|
||||
// Fetch user groups and other dashboard data
|
||||
try {
|
||||
await groupStore.fetchUserGroups()
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle priority action from PersonalStatusCard
|
||||
const handlePriorityAction = (action: any) => {
|
||||
// Navigate to the appropriate page based on action type
|
||||
router.push(action.actionUrl)
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: `Navigating to ${action.title}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle create action from PersonalStatusCard
|
||||
const handleCreateAction = () => {
|
||||
// For now, navigate to lists page
|
||||
router.push('/lists')
|
||||
}
|
||||
|
||||
// Handle FAB actions
|
||||
const handleScanReceipt = () => {
|
||||
// Open receipt scanning interface
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: 'Receipt scanner opening...',
|
||||
})
|
||||
// TODO: Implement receipt scanning
|
||||
}
|
||||
|
||||
const handleCreateList = () => {
|
||||
// Open list creation modal
|
||||
router.push('/lists?create=true')
|
||||
}
|
||||
|
||||
const handleInviteMember = () => {
|
||||
// Open member invitation modal
|
||||
notificationStore.addNotification({
|
||||
type: 'info',
|
||||
message: 'Invite member feature coming soon!',
|
||||
})
|
||||
// TODO: Implement member invitation
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
@apply min-h-screen bg-neutral-50;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
@apply bg-white border-b border-neutral-200 sticky top-0 z-20;
|
||||
@apply shadow-soft;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply max-w-7xl mx-auto px-4 py-6 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-semibold text-neutral-900;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
@apply max-w-4xl mx-auto px-4 py-6 space-y-8;
|
||||
/* Ensure content is above FAB on mobile */
|
||||
@apply pb-32 lg:pb-8;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
/* Above fold - most important */
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.activity-section {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.quick-actions-section {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-lg font-medium text-neutral-900;
|
||||
}
|
||||
|
||||
.quick-actions-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.header-content {
|
||||
@apply px-4 py-4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
@apply px-4 py-4 space-y-6;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.dashboard-content>section {
|
||||
animation: fade-in 300ms ease-out;
|
||||
}
|
||||
|
||||
.dashboard-content>section:nth-child(2) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.dashboard-content>section:nth-child(3) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.dashboard-content.loading {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
.dashboard-content.loading>section {
|
||||
@apply animate-skeleton;
|
||||
}
|
||||
</style>
|
@ -1,157 +1,477 @@
|
||||
<template>
|
||||
<main class="container mx-auto max-w-3xl p-4">
|
||||
<Heading :level="1" class="mb-6">{{ group?.name || t('householdSettings.title', 'Household Settings') }}
|
||||
</Heading>
|
||||
|
||||
<!-- Rename household -->
|
||||
<section class="mb-8">
|
||||
<Heading :level="3" class="mb-2">{{ t('householdSettings.rename.title', 'Rename Household') }}</Heading>
|
||||
<div class="flex gap-2 w-full max-w-md">
|
||||
<Input v-model="editedName" class="flex-1"
|
||||
:placeholder="t('householdSettings.rename.placeholder', 'New household name')" />
|
||||
<Button :disabled="savingName || !editedName.trim() || editedName === group?.name" @click="saveName">
|
||||
<Spinner v-if="savingName" size="sm" class="mr-1" />
|
||||
{{ t('shared.save', 'Save') }}
|
||||
</Button>
|
||||
<div class="household-settings-page">
|
||||
<!-- Page Header -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-main">
|
||||
<BaseIcon name="heroicons:cog-8-tooth" class="header-icon" />
|
||||
<div class="header-text">
|
||||
<Heading :level="1">Household Settings</Heading>
|
||||
<p class="subtitle">Manage your household's profile, members, and rules.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert v-if="nameError" type="error" :message="nameError" class="mt-2" />
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<!-- Members list -->
|
||||
<section class="mb-8">
|
||||
<Heading :level="3" class="mb-4">{{ t('householdSettings.members.title', 'Members') }}</Heading>
|
||||
<ul v-if="group" class="space-y-3">
|
||||
<li v-for="member in group.members" :key="member.id"
|
||||
class="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 p-3 rounded-md">
|
||||
<span>{{ member.email }}</span>
|
||||
<Button v-if="canRemove(member)" variant="ghost" color="danger" size="sm"
|
||||
@click="confirmRemove(member)">
|
||||
{{ t('householdSettings.members.remove', 'Remove') }}
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
<Alert v-else type="info" :message="t('householdSettings.members.loading', 'Loading members...')" />
|
||||
</section>
|
||||
<!-- Page Content -->
|
||||
<main class="page-content">
|
||||
<div class="settings-grid">
|
||||
<!-- Left Column: Main Settings -->
|
||||
<div class="settings-column">
|
||||
<!-- Household Profile Card -->
|
||||
<Card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<BaseIcon name="heroicons:home-modern" />
|
||||
<h3 class="card-title">Household Profile</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<Input v-model="editedName" label="Household Name"
|
||||
:placeholder="group?.name || 'Your Household Name'" />
|
||||
<Button @click="saveName" :loading="savingName" :disabled="!isNameChanged">
|
||||
Save Name
|
||||
</Button>
|
||||
<Alert v-if="nameError" type="error" :message="nameError" class="mt-2" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Invite manager -->
|
||||
<InviteManager v-if="group" :group-id="group.id" />
|
||||
<!-- Theme Section -->
|
||||
<Card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<BaseIcon name="heroicons:paint-brush" />
|
||||
<h3 class="card-title">Theme</h3>
|
||||
</div>
|
||||
</template>
|
||||
<p class="card-description">Choose a primary color for your household.</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button v-for="c in themeOptions" :key="c" :style="{ backgroundColor: c }"
|
||||
class="w-8 h-8 rounded-full border-2 flex items-center justify-center"
|
||||
:class="{ 'ring-2 ring-primary-500': selectedTheme === c }" @click="selectTheme(c)" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button @click="saveTheme" :loading="savingTheme" :disabled="!themeChanged">
|
||||
Save Theme
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Remove member confirm dialog -->
|
||||
<Dialog v-model="showRemoveDialog">
|
||||
<!-- House Rules Card -->
|
||||
<Card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<BaseIcon name="heroicons:book-open" />
|
||||
<h3 class="card-title">House Rules</h3>
|
||||
</div>
|
||||
</template>
|
||||
<p class="card-description">Set clear expectations for everyone in the household.</p>
|
||||
<Textarea v-model="houseRules" placeholder="e.g., "Clean up the kitchen after use.""
|
||||
rows="5" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button @click="saveRules" :loading="savingRules">
|
||||
Save Rules
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<Card class="setting-card" variant="soft" color="error">
|
||||
<template #header>
|
||||
<div class="card-header-error">
|
||||
<BaseIcon name="heroicons:exclamation-triangle" />
|
||||
<h3 class="card-title-error">Danger Zone</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="danger-action">
|
||||
<div>
|
||||
<h4 class="font-semibold">Leave Household</h4>
|
||||
<p class="text-sm text-error-700 dark:text-error-300">You will lose access to all
|
||||
shared data.</p>
|
||||
</div>
|
||||
<Button variant="outline" color="error" @click="confirmLeaveHousehold">Leave</Button>
|
||||
</div>
|
||||
<div class="danger-action">
|
||||
<div>
|
||||
<h4 class="font-semibold">Delete Household</h4>
|
||||
<p class="text-sm text-error-700 dark:text-error-300">This will permanently delete
|
||||
the household and all
|
||||
its data for everyone. This action cannot be undone.</p>
|
||||
</div>
|
||||
<Button variant="solid" color="error" @click="confirmDeleteHousehold">Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Members & Invites -->
|
||||
<div class="settings-column">
|
||||
<!-- Manage Members Card -->
|
||||
<Card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<BaseIcon name="heroicons:users" />
|
||||
<h3 class="card-title">Manage Members</h3>
|
||||
</div>
|
||||
</template>
|
||||
<ul v-if="group" class="space-y-3">
|
||||
<li v-for="member in group.members" :key="member.id" class="member-item">
|
||||
<div class="member-info">
|
||||
<div class="member-avatar">{{ member.email.charAt(0).toUpperCase() }}</div>
|
||||
<div class="member-details">
|
||||
<span class="member-email">{{ member.email }}</span>
|
||||
<span class="member-role">{{ member.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canRemove(member)" variant="ghost" color="error" size="sm"
|
||||
@click="confirmRemove(member)">
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
<Alert v-else type="info" message="Loading members..." />
|
||||
</Card>
|
||||
|
||||
<!-- Invite Manager Card -->
|
||||
<Card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<BaseIcon name="heroicons:envelope-open" />
|
||||
<h3 class="card-title">Invite New Members</h3>
|
||||
</div>
|
||||
</template>
|
||||
<InviteManager v-if="group" :group-id="group.id" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<Dialog v-model="showConfirmDialog">
|
||||
<div class="space-y-4">
|
||||
<Heading :level="3">{{ t('householdSettings.members.confirmTitle', 'Remove member?') }}</Heading>
|
||||
<p>{{ confirmPrompt }}</p>
|
||||
<Heading :level="3">{{ confirmDialog.title }}</Heading>
|
||||
<p>{{ confirmDialog.message }}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" color="neutral" @click="showRemoveDialog = false">{{ t('shared.cancel',
|
||||
'Cancel') }}</Button>
|
||||
<Button variant="solid" color="danger" :disabled="removing" @click="removeConfirmed">
|
||||
<Spinner v-if="removing" size="sm" class="mr-1" />
|
||||
{{ t('shared.remove', 'Remove') }}
|
||||
<Button variant="ghost" color="neutral" @click="showConfirmDialog = false">Cancel</Button>
|
||||
<Button variant="solid" :color="confirmDialog.confirmColor" :loading="isConfirming"
|
||||
@click="executeConfirmAction">
|
||||
{{ confirmDialog.confirmText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import Heading from '@/components/ui/Heading.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import Spinner from '@/components/ui/Spinner.vue'
|
||||
import Dialog from '@/components/ui/Dialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Heading, Input, Button, Alert, Dialog, Card, Textarea } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import InviteManager from '@/components/InviteManager.vue'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
|
||||
interface Member {
|
||||
id: number
|
||||
email: string
|
||||
role?: string
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
name: string
|
||||
members: Member[]
|
||||
rules?: string
|
||||
meta?: {
|
||||
theme_color?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const groupId = Number(route.params.id || route.params.groupId)
|
||||
const group = ref<Group | null>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
// Edit name state
|
||||
const editedName = ref('')
|
||||
const savingName = ref(false)
|
||||
const nameError = ref<string | null>(null)
|
||||
const isNameChanged = computed(() => group.value && editedName.value.trim() && editedName.value.trim() !== group.value.name)
|
||||
|
||||
const showRemoveDialog = ref(false)
|
||||
const memberToRemove = ref<Member | null>(null)
|
||||
const removing = ref(false)
|
||||
// House rules state
|
||||
const houseRules = ref('')
|
||||
const savingRules = ref(false)
|
||||
|
||||
const confirmPrompt = computed(() => {
|
||||
const email = memberToRemove.value?.email ?? ''
|
||||
return t('householdSettings.members.confirmMessage', { email }, `Are you sure you want to remove ${email} from the household?`)
|
||||
// Theme state
|
||||
const themeOptions = ['#ff5757', '#ffb01f', '#00d084', '#1e90ff', '#9333ea']
|
||||
const selectedTheme = ref<string>('')
|
||||
const originalTheme = ref<string>('')
|
||||
const savingTheme = ref(false)
|
||||
const themeChanged = computed(() => selectedTheme.value && selectedTheme.value !== originalTheme.value)
|
||||
|
||||
// Confirmation Dialog State
|
||||
const showConfirmDialog = ref(false)
|
||||
const isConfirming = ref(false)
|
||||
const confirmDialog = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: '',
|
||||
confirmColor: 'primary' as 'primary' | 'error',
|
||||
action: () => { }
|
||||
})
|
||||
|
||||
function canRemove(member: Member): boolean {
|
||||
return member.role !== 'owner'
|
||||
// Cannot remove self, cannot remove owner unless you are an owner
|
||||
if (member.id === auth.user?.id) return false
|
||||
const currentUser = group.value?.members.find(m => m.id === auth.user?.id)
|
||||
if (currentUser?.role !== 'owner' && member.role === 'owner') return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function fetchGroup() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const res = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
|
||||
group.value = res.data as Group
|
||||
editedName.value = group.value.name
|
||||
const { data } = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
|
||||
group.value = data
|
||||
editedName.value = data.name
|
||||
houseRules.value = data.rules || ''
|
||||
originalTheme.value = data.meta?.theme_color || themeOptions[0]
|
||||
selectedTheme.value = originalTheme.value
|
||||
} catch (err: any) {
|
||||
loadError.value = err?.response?.data?.detail || err.message || t('householdSettings.loadError', 'Failed to load household info')
|
||||
notifications.addNotification({ type: 'error', message: 'Failed to load household info.' })
|
||||
router.push('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
if (!group.value || editedName.value.trim() === group.value.name) return
|
||||
if (!isNameChanged.value) return
|
||||
savingName.value = true
|
||||
nameError.value = null
|
||||
try {
|
||||
const res = await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { name: editedName.value.trim() })
|
||||
group.value = { ...group.value, name: res.data.name }
|
||||
const { data } = await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { name: editedName.value.trim() })
|
||||
if (group.value) {
|
||||
group.value.name = data.name
|
||||
}
|
||||
notifications.addNotification({ type: 'success', message: 'Household name updated!' })
|
||||
} catch (err: any) {
|
||||
nameError.value = err?.response?.data?.detail || err.message || t('householdSettings.rename.error', 'Failed to rename household')
|
||||
nameError.value = err?.response?.data?.detail || 'Failed to rename household'
|
||||
} finally {
|
||||
savingName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(member: Member) {
|
||||
memberToRemove.value = member
|
||||
showRemoveDialog.value = true
|
||||
async function saveRules() {
|
||||
savingRules.value = true
|
||||
try {
|
||||
await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { rules: houseRules.value })
|
||||
if (group.value) {
|
||||
group.value.rules = houseRules.value
|
||||
}
|
||||
notifications.addNotification({ type: 'success', message: 'House rules saved.' })
|
||||
} catch (err) {
|
||||
notifications.addNotification({ type: 'error', message: 'Failed to save rules.' })
|
||||
} finally {
|
||||
savingRules.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeConfirmed() {
|
||||
if (!group.value || !memberToRemove.value) return
|
||||
removing.value = true
|
||||
async function saveTheme() {
|
||||
savingTheme.value = true
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId), String(memberToRemove.value.id)))
|
||||
group.value.members = group.value.members.filter(m => m.id !== memberToRemove.value!.id)
|
||||
showRemoveDialog.value = false
|
||||
} catch (err: any) {
|
||||
// show error alert inside dialog maybe
|
||||
await apiClient.patch(API_ENDPOINTS.GROUPS.SETTINGS(String(groupId)), { theme_color: selectedTheme.value })
|
||||
originalTheme.value = selectedTheme.value
|
||||
notifications.addNotification({ type: 'success', message: 'House theme updated.' })
|
||||
} catch (err) {
|
||||
notifications.addNotification({ type: 'error', message: 'Failed to save theme.' })
|
||||
} finally {
|
||||
removing.value = false
|
||||
savingTheme.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(member: Member) {
|
||||
showConfirmDialog.value = true
|
||||
confirmDialog.value = {
|
||||
title: 'Remove Member?',
|
||||
message: `Are you sure you want to remove ${member.email} from the household? They will lose access to all shared data.`,
|
||||
confirmText: 'Yes, Remove',
|
||||
confirmColor: 'error',
|
||||
action: () => removeMember(member.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(memberId: number) {
|
||||
if (!group.value) return
|
||||
isConfirming.value = true
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId), String(memberId)))
|
||||
group.value.members = group.value.members.filter(m => m.id !== memberId)
|
||||
notifications.addNotification({ type: 'success', message: 'Member removed.' })
|
||||
showConfirmDialog.value = false
|
||||
} catch (err: any) {
|
||||
notifications.addNotification({ type: 'error', message: 'Failed to remove member.' })
|
||||
} finally {
|
||||
isConfirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmLeaveHousehold() {
|
||||
showConfirmDialog.value = true
|
||||
confirmDialog.value = {
|
||||
title: 'Leave Household?',
|
||||
message: 'Are you sure you want to leave this household? This action cannot be undone.',
|
||||
confirmText: 'Yes, Leave',
|
||||
confirmColor: 'error',
|
||||
action: leaveHousehold
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveHousehold() {
|
||||
isConfirming.value = true
|
||||
try {
|
||||
await apiClient.post(API_ENDPOINTS.GROUPS.LEAVE(String(groupId)))
|
||||
notifications.addNotification({ type: 'success', message: "You have left the household." })
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
notifications.addNotification({ type: 'error', message: "Failed to leave household." })
|
||||
} finally {
|
||||
isConfirming.value = false
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteHousehold() {
|
||||
showConfirmDialog.value = true
|
||||
confirmDialog.value = {
|
||||
title: 'Delete Household?',
|
||||
message: 'This will permanently delete the household and all associated data for all members. This action cannot be undone. Are you absolutely sure?',
|
||||
confirmText: 'Yes, Delete Forever',
|
||||
confirmColor: 'error',
|
||||
action: deleteHousehold
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHousehold() {
|
||||
isConfirming.value = true
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
|
||||
notifications.addNotification({ type: 'success', message: "Household has been deleted." })
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
notifications.addNotification({ type: 'error', message: "Failed to delete household." })
|
||||
} finally {
|
||||
isConfirming.value = false
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function executeConfirmAction() {
|
||||
confirmDialog.value.action()
|
||||
}
|
||||
|
||||
function selectTheme(c: string) {
|
||||
selectedTheme.value = c
|
||||
}
|
||||
|
||||
onMounted(fetchGroup)
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.household-settings-page {
|
||||
@apply max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
@apply pb-8 border-b border-border-primary mb-8;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@apply w-12 h-12 text-primary-500;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@apply text-lg text-text-secondary mt-1;
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
.settings-grid {
|
||||
@apply grid grid-cols-1 lg:grid-cols-3 gap-8;
|
||||
}
|
||||
|
||||
.settings-column:first-child {
|
||||
@apply lg:col-span-2 space-y-8;
|
||||
}
|
||||
|
||||
.settings-column:last-child {
|
||||
@apply space-y-8;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.setting-card {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply flex items-center gap-3 text-lg font-semibold text-text-primary;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
@apply text-sm text-text-secondary mb-4 -mt-2;
|
||||
}
|
||||
|
||||
/* Member List */
|
||||
.member-item {
|
||||
@apply flex items-center justify-between p-3 bg-surface-soft rounded-lg;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
@apply w-10 h-10 bg-primary-100 text-primary-600 dark:bg-primary-900/50 dark:text-primary-300;
|
||||
@apply rounded-full flex items-center justify-center font-bold text-lg;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.member-email {
|
||||
@apply font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
@apply text-sm text-text-secondary capitalize;
|
||||
}
|
||||
|
||||
/* Danger Zone */
|
||||
.card-header-error {
|
||||
@apply flex items-center gap-3 text-lg font-semibold text-error-700 dark:text-error-300;
|
||||
}
|
||||
|
||||
.card-title-error {
|
||||
@apply text-error-800 dark:text-error-200;
|
||||
}
|
||||
|
||||
.danger-action {
|
||||
@apply flex items-center justify-between p-4 rounded-lg;
|
||||
@apply bg-error-100/50 dark:bg-error-900/20;
|
||||
}
|
||||
</style>
|
@ -37,7 +37,7 @@
|
||||
Email Magic Link
|
||||
</button>
|
||||
|
||||
<AuthenticationSheet ref="sheet" />
|
||||
<AuthenticationSheet v-model="isSheetOpen" ref="sheet" />
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
||||
@ -70,11 +70,12 @@ const email = ref('');
|
||||
const password = ref('');
|
||||
const isPwdVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const isSheetOpen = ref(false);
|
||||
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
|
||||
|
||||
const sheet = ref<InstanceType<typeof AuthenticationSheet>>()
|
||||
function openSheet() {
|
||||
sheet.value?.show()
|
||||
isSheetOpen.value = true;
|
||||
}
|
||||
|
||||
const isValidEmail = (val: string): boolean => {
|
||||
|
257
fe/src/sw.ts
257
fe/src/sw.ts
@ -17,107 +17,216 @@ import {
|
||||
createHandlerBoundToURL,
|
||||
} from 'workbox-precaching';
|
||||
import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
||||
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import { BackgroundSyncPlugin } from 'workbox-background-sync';
|
||||
import type { WorkboxPlugin } from 'workbox-core/types';
|
||||
|
||||
// Create a background sync plugin instance
|
||||
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', {
|
||||
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes)
|
||||
});
|
||||
|
||||
// Initialize service worker
|
||||
const initializeSW = async () => {
|
||||
try {
|
||||
await self.skipWaiting();
|
||||
clientsClaim();
|
||||
// console.log('Service Worker initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during service worker initialization:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Use with precache injection
|
||||
// vite-plugin-pwa will populate self.__WB_MANIFEST
|
||||
// Precache all assets generated by Vite
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// Clean up outdated caches
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
// Cache app shell and static assets with Cache First strategy
|
||||
// Take control of all pages immediately
|
||||
clientsClaim();
|
||||
|
||||
// Background sync plugin
|
||||
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
|
||||
maxRetentionTime: 24 * 60, // Retry for 24 hours
|
||||
});
|
||||
|
||||
// Cache API responses with NetworkFirst strategy
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 3,
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
// Create cache keys that ignore auth headers for better cache hits
|
||||
const url = new URL(request.url);
|
||||
return url.pathname + url.search;
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Cache static assets with CacheFirst strategy
|
||||
registerRoute(
|
||||
({ request }) =>
|
||||
request.destination === 'style' ||
|
||||
request.destination === 'script' ||
|
||||
request.destination === 'image' ||
|
||||
request.destination === 'font',
|
||||
request.destination === 'font' ||
|
||||
request.destination === 'style',
|
||||
new CacheFirst({
|
||||
cacheName: 'static-assets',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}) as WorkboxPlugin,
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 60,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
}) as WorkboxPlugin,
|
||||
{
|
||||
cacheWillUpdate: async ({ response }) => {
|
||||
return response.status === 200 ? response : null;
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Cache API calls with Network First strategy and Background Sync for failed requests
|
||||
// Cache navigation requests with StaleWhileRevalidate
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-cache',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}) as WorkboxPlugin,
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
}) as WorkboxPlugin,
|
||||
bgSyncPlugin, // Add background sync plugin for failed requests
|
||||
],
|
||||
({ request }) => request.mode === 'navigate',
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'navigation-cache',
|
||||
})
|
||||
);
|
||||
|
||||
// Non-SSR fallbacks to index.html
|
||||
// Production SSR fallbacks to offline.html (except for dev)
|
||||
// Using environment variables defined in vite.config.ts and injected by Vite
|
||||
declare const __PWA_FALLBACK_HTML__: string;
|
||||
declare const __PWA_SERVICE_WORKER_REGEX__: string;
|
||||
// Register background sync route for API calls
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/') &&
|
||||
!url.pathname.includes('/auth/'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-mutations',
|
||||
plugins: [bgSyncPlugin],
|
||||
}),
|
||||
'POST'
|
||||
);
|
||||
|
||||
// Use fallback values if not defined
|
||||
const PWA_FALLBACK_HTML = typeof __PWA_FALLBACK_HTML__ !== 'undefined' ? __PWA_FALLBACK_HTML__ : '/index.html';
|
||||
const PWA_SERVICE_WORKER_REGEX = typeof __PWA_SERVICE_WORKER_REGEX__ !== 'undefined'
|
||||
? new RegExp(__PWA_SERVICE_WORKER_REGEX__)
|
||||
: /^(sw|workbox)-.*\.js$/;
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/') &&
|
||||
!url.pathname.includes('/auth/'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-mutations',
|
||||
plugins: [bgSyncPlugin],
|
||||
}),
|
||||
'PUT'
|
||||
);
|
||||
|
||||
// Register navigation route for SPA fallback
|
||||
if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
|
||||
// Cache the index.html explicitly for navigation fallback
|
||||
registerRoute(
|
||||
({ request }) => request.mode === 'navigate',
|
||||
new NetworkFirst({
|
||||
cacheName: 'navigation-cache',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}) as WorkboxPlugin,
|
||||
],
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/') &&
|
||||
!url.pathname.includes('/auth/'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-mutations',
|
||||
plugins: [bgSyncPlugin],
|
||||
}),
|
||||
'DELETE'
|
||||
);
|
||||
|
||||
// Handle offline fallback
|
||||
const OFFLINE_FALLBACK_URL = '/offline.html';
|
||||
|
||||
// Cache the offline fallback page
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open('offline-fallback')
|
||||
.then(cache => cache.add(OFFLINE_FALLBACK_URL))
|
||||
);
|
||||
});
|
||||
|
||||
// Serve offline fallback when network fails
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
return await fetch(event.request);
|
||||
} catch {
|
||||
const cached = await caches.match(OFFLINE_FALLBACK_URL);
|
||||
return cached ?? new Response('Offline', { status: 503, statusText: 'Offline' });
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for messages from main thread
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SYNC_BACKGROUND') {
|
||||
// Trigger background sync
|
||||
self.registration.sync.register('background-sync');
|
||||
}
|
||||
|
||||
if (event.data?.type === 'CLEAR_CACHE') {
|
||||
// Clear specific cache
|
||||
const cacheName = event.data.cacheName;
|
||||
caches.delete(cacheName);
|
||||
}
|
||||
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// Background sync event
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'background-sync') {
|
||||
console.log('Background sync triggered');
|
||||
}
|
||||
});
|
||||
|
||||
// Show notification when app is updated
|
||||
self.addEventListener('controllerchange', () => {
|
||||
// Notify main thread that SW has updated
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SW_UPDATED',
|
||||
message: 'App updated! Refresh to see changes.'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Progressive enhancement: add install prompt handling
|
||||
let deferredPrompt: any;
|
||||
|
||||
self.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Notify main thread that install is available
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'INSTALL_AVAILABLE',
|
||||
message: 'App can be installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Handle install event
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker installing...');
|
||||
|
||||
// Cache critical resources immediately
|
||||
event.waitUntil(
|
||||
caches.open('critical-cache-v1').then(cache => {
|
||||
return cache.addAll([
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/offline.html'
|
||||
]);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Register fallback for offline navigation
|
||||
registerRoute(
|
||||
new NavigationRoute(createHandlerBoundToURL(PWA_FALLBACK_HTML), {
|
||||
denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/],
|
||||
}),
|
||||
// Handle activation
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker activated');
|
||||
|
||||
// Clean up old caches
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(cacheName => cacheName.startsWith('critical-cache-') &&
|
||||
cacheName !== 'critical-cache-v1')
|
||||
.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the service worker
|
||||
initializeSW();
|
||||
// Export for TypeScript
|
||||
export default null;
|
@ -5,11 +5,14 @@ export interface List {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
type: 'shopping' | 'todo' | 'custom'
|
||||
is_complete: boolean
|
||||
group_id?: number | null
|
||||
items: Item[]
|
||||
version: number
|
||||
updated_at: string
|
||||
created_at: string
|
||||
archived_at?: string | null
|
||||
expenses?: Expense[]
|
||||
}
|
||||
|
||||
|
15
fe/src/utils/analytics.ts
Normal file
15
fe/src/utils/analytics.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export type AnalyticsPayload = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Lightweight analytics stub. In production this should forward to a real
|
||||
* provider (PostHog, Plausible, etc.). For now we just log for visibility and
|
||||
* keep a typed API so that calls compile even when the real service is absent.
|
||||
*/
|
||||
export function track(event: string, payload: AnalyticsPayload = {}) {
|
||||
if (import.meta.env.MODE === 'production') {
|
||||
// TODO: connect real analytics provider
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[analytics] ${event}`, payload)
|
||||
}
|
||||
}
|
45
fe/src/utils/offlineQueue.ts
Normal file
45
fe/src/utils/offlineQueue.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { get, set, del } from 'idb-keyval'
|
||||
|
||||
export type OfflineOperation = {
|
||||
id: string
|
||||
type: string
|
||||
payload: any
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const STORE_KEY = 'offline-queue'
|
||||
|
||||
async function readAll(): Promise<OfflineOperation[]> {
|
||||
const data = await get<OfflineOperation[]>(STORE_KEY)
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function enqueue(op: Omit<OfflineOperation, 'id' | 'timestamp'>) {
|
||||
const current = await readAll()
|
||||
current.push({ ...op, id: crypto.randomUUID(), timestamp: Date.now() })
|
||||
await set(STORE_KEY, current)
|
||||
}
|
||||
|
||||
export async function flush(handler: (op: OfflineOperation) => Promise<void>) {
|
||||
const queue = await readAll()
|
||||
for (const op of queue) {
|
||||
try {
|
||||
await handler(op)
|
||||
await remove(op.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to process offline op', op, err)
|
||||
// stop flush on first failure to avoid loop
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(id: string) {
|
||||
const current = await readAll()
|
||||
const updated = current.filter((o) => o.id !== id)
|
||||
await set(STORE_KEY, updated)
|
||||
}
|
||||
|
||||
export async function clear() {
|
||||
await del(STORE_KEY)
|
||||
}
|
@ -13,29 +13,225 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#FF7B54',
|
||||
// Design System Colors from refactor plan
|
||||
primary: {
|
||||
DEFAULT: '#3b82f6',
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6', // Main primary color
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
success: {
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
200: '#a7f3d0',
|
||||
300: '#6ee7b7',
|
||||
400: '#34d399',
|
||||
500: '#10b981', // Main success color
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
800: '#065f46',
|
||||
900: '#064e3b',
|
||||
950: '#022c22',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b', // Main warning color
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
950: '#4c2209',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444', // Main error color
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
950: '#661010',
|
||||
},
|
||||
neutral: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b', // Main neutral color
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
// Legacy colors for backward compatibility
|
||||
secondary: '#FFB26B',
|
||||
accent: '#FFD56B',
|
||||
info: '#54C7FF',
|
||||
success: '#A0E7A0',
|
||||
warning: '#FFD56B',
|
||||
danger: '#FF4D4D',
|
||||
dark: '#393E46',
|
||||
light: '#FFF8F0',
|
||||
black: '#000000',
|
||||
neutral: '#64748B',
|
||||
// Additional color aliases for design tokens
|
||||
'surface-primary': '#ffffff', // Light surface
|
||||
'surface-secondary': '#f8fafc',
|
||||
'surface-elevated': '#ffffff', // elevated surface same as primary for now
|
||||
'surface-hover': '#f1f5f9', // neutral-100, subtle hover for surfaces
|
||||
'surface-soft': '#f8fafc', // neutral-50, extra soft surface
|
||||
// Border colors
|
||||
'border-secondary': '#e2e8f0', // neutral-200
|
||||
'border-subtle': '#cbd5e1', // neutral-300
|
||||
// Text colors
|
||||
'text-primary': '#0f172a', // neutral-900
|
||||
'text-secondary': '#475569', // neutral-600
|
||||
'text-tertiary': '#94a3b8', // neutral-400
|
||||
// Border primary alias (light)
|
||||
'border-primary': '#bfdbfe', // primary-200
|
||||
},
|
||||
spacing: {
|
||||
1: '4px',
|
||||
4: '16px',
|
||||
6: '24px',
|
||||
8: '32px',
|
||||
12: '48px',
|
||||
16: '64px',
|
||||
// Design system spacing (base unit: 4px)
|
||||
'1': '4px',
|
||||
'2': '8px',
|
||||
'3': '12px',
|
||||
'4': '16px', // Component spacing
|
||||
'5': '20px',
|
||||
'6': '24px', // Component spacing
|
||||
'7': '28px',
|
||||
'8': '32px', // Component spacing
|
||||
'9': '36px',
|
||||
'10': '40px',
|
||||
'11': '44px',
|
||||
'12': '48px', // Section spacing
|
||||
'14': '56px',
|
||||
'16': '64px', // Section spacing
|
||||
'20': '80px',
|
||||
'24': '96px',
|
||||
'28': '112px',
|
||||
'32': '128px',
|
||||
// Alias tokens for design system (used as spacing-[size])
|
||||
'spacing-xs': '4px', // 1
|
||||
'spacing-sm': '8px', // 2
|
||||
'spacing-md': '16px', // 4
|
||||
'spacing-lg': '24px', // 6
|
||||
'spacing-xl': '32px', // 8
|
||||
},
|
||||
fontFamily: {
|
||||
// Typography system
|
||||
sans: [
|
||||
'Inter',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'San Francisco',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Helvetica Neue',
|
||||
'Arial',
|
||||
'sans-serif'
|
||||
],
|
||||
mono: [
|
||||
'JetBrains Mono',
|
||||
'SFMono-Regular',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'Liberation Mono',
|
||||
'Courier New',
|
||||
'monospace'
|
||||
],
|
||||
// Legacy
|
||||
hand: ['"Patrick Hand"', 'cursive'],
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
},
|
||||
fontSize: {
|
||||
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'base': ['1rem', { lineHeight: '1.5rem' }],
|
||||
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||
'5xl': ['3rem', { lineHeight: '1' }],
|
||||
'6xl': ['3.75rem', { lineHeight: '1' }],
|
||||
// Alias font-size tokens for design system
|
||||
'label-xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'label-sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'body-sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'heading-lg': ['1.5rem', { lineHeight: '2rem' }],
|
||||
},
|
||||
transitionDuration: {
|
||||
// Motion system
|
||||
'micro': '150ms', // Micro-interactions
|
||||
'page': '300ms', // Page transitions
|
||||
'slow': '500ms', // Loading states
|
||||
// Medium alias (used in components)
|
||||
'medium': '300ms',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
// Custom timing functions for design system
|
||||
'micro': 'cubic-bezier(0.4, 0, 0.2, 1)', // alias for ease-out micro interactions
|
||||
'medium': 'cubic-bezier(0.4, 0, 0.2, 1)', // same curve, longer duration
|
||||
'page': 'cubic-bezier(0.4, 0, 0.6, 1)', // ease-in-out for page transitions
|
||||
},
|
||||
keyframes: {
|
||||
// Skeleton loading animation
|
||||
skeleton: {
|
||||
'0%': { opacity: '1' },
|
||||
'50%': { opacity: '0.4' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
// Fade in animation
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
// Scale in animation
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'skeleton': 'skeleton 1.5s ease-in-out infinite',
|
||||
'fade-in': 'fadeIn 300ms ease-out',
|
||||
'scale-in': 'scaleIn 150ms ease-out',
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 8px rgba(0, 0, 0, 0.04)',
|
||||
'medium': '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
'strong': '0 8px 24px rgba(0, 0, 0, 0.12)',
|
||||
'floating': '0 12px 32px rgba(0, 0, 0, 0.16)',
|
||||
// Alias box-shadow tokens matching naming convention in components
|
||||
'elevation-soft': '0 2px 8px rgba(0, 0, 0, 0.04)',
|
||||
'elevation-medium': '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
'elevation-strong': '0 8px 24px rgba(0, 0, 0, 0.12)',
|
||||
'elevation-high': '0 16px 48px rgba(0,0,0,0.18)',
|
||||
},
|
||||
borderRadius: {
|
||||
// Radius alias tokens
|
||||
'radius-sm': '4px',
|
||||
'radius-md': '8px',
|
||||
'radius-lg': '12px',
|
||||
'radius-full': '9999px',
|
||||
},
|
||||
zIndex: {
|
||||
60: '60',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1,12 +1,25 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext",
|
||||
"WebWorker"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "doe",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user